mirror of
https://github.com/Mintplex-Labs/anything-llm
synced 2026-04-25 17:15:37 +02:00
Merge branch 'master' of github.com:Mintplex-Labs/anything-llm into 5060-bug-agent-interactions-agent-are-not-persisted-to-thread-history-via-api
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
name: Publish AnythingLLM Docker image on Release (amd64 & arm64)
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
|
||||
2
.github/workflows/build-and-push-image.yaml
vendored
2
.github/workflows/build-and-push-image.yaml
vendored
@@ -7,7 +7,7 @@
|
||||
name: Publish AnythingLLM Primary Docker image (amd64/arm64)
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
name: Check package versions
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
|
||||
2
.github/workflows/check-translations.yaml
vendored
2
.github/workflows/check-translations.yaml
vendored
@@ -5,7 +5,7 @@
|
||||
name: Verify translations files
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
|
||||
84
.github/workflows/cleanup-qa-tag.yaml
vendored
84
.github/workflows/cleanup-qa-tag.yaml
vendored
@@ -4,23 +4,81 @@ name: Cleanup QA GHCR Image
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed, unlabeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to clean up (e.g., 123)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: Delete QA GHCR image tag
|
||||
cleanup-manual:
|
||||
name: Delete QA GHCR image tag (manual)
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
${{ (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'PR: Ready for QA')) ||
|
||||
(github.event.action == 'unlabeled' && github.event.label.name == 'PR: Ready for QA') }}
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Delete PR tag from GHCR
|
||||
uses: actions/delete-package-versions@v5
|
||||
continue-on-error: true # Package may not exist if build never ran
|
||||
with:
|
||||
owner: ${{ github.repository_owner }}
|
||||
package-name: ${{ github.event.repository.name }}
|
||||
package-type: container
|
||||
min-versions-to-keep: 0
|
||||
ignore-versions: '^(?!pr-${{ github.event.pull_request.number }}$).*$'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ALLM_RW_PACKAGES }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
run: |
|
||||
# Must use lowercase - packages are published with lowercase owner
|
||||
ORG_LC="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
REPO_LC="${GITHUB_REPOSITORY#*/}"
|
||||
REPO_LC="${REPO_LC,,}"
|
||||
|
||||
echo "Looking for tag: pr-${PR_NUMBER}"
|
||||
echo "Package: /orgs/${ORG_LC}/packages/container/${REPO_LC}/versions"
|
||||
|
||||
VERSION_ID=$(gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--paginate \
|
||||
"/orgs/${ORG_LC}/packages/container/${REPO_LC}/versions" \
|
||||
--jq ".[] | select(.metadata.container.tags[] == \"pr-${PR_NUMBER}\") | .id")
|
||||
|
||||
if [ -n "$VERSION_ID" ]; then
|
||||
echo "Deleting package version $VERSION_ID (tag: pr-${PR_NUMBER})"
|
||||
gh api \
|
||||
--method DELETE \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"/orgs/${ORG_LC}/packages/container/${REPO_LC}/versions/$VERSION_ID"
|
||||
else
|
||||
echo "No package found with tag pr-${PR_NUMBER}, skipping cleanup"
|
||||
fi
|
||||
|
||||
cleanup-auto:
|
||||
name: Delete QA GHCR image tag (auto)
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'PR: Ready for QA')) ||
|
||||
(github.event.action == 'unlabeled' && github.event.label.name == 'PR: Ready for QA')
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Delete PR tag from GHCR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ALLM_RW_PACKAGES }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
# Must use lowercase - packages are published with lowercase owner
|
||||
ORG_LC="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
REPO_LC="${GITHUB_REPOSITORY#*/}"
|
||||
REPO_LC="${REPO_LC,,}"
|
||||
|
||||
VERSION_ID=$(gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--paginate \
|
||||
"/orgs/${ORG_LC}/packages/container/${REPO_LC}/versions" \
|
||||
--jq ".[] | select(.metadata.container.tags[] == \"pr-${PR_NUMBER}\") | .id" \
|
||||
2>/dev/null || true)
|
||||
|
||||
if [ -n "$VERSION_ID" ]; then
|
||||
echo "Deleting package version $VERSION_ID (tag: pr-${PR_NUMBER})"
|
||||
gh api \
|
||||
--method DELETE \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"/orgs/${ORG_LC}/packages/container/${REPO_LC}/versions/$VERSION_ID"
|
||||
else
|
||||
echo "No package found with tag pr-${PR_NUMBER}, skipping cleanup"
|
||||
fi
|
||||
|
||||
119
.github/workflows/dev-build.yaml
vendored
119
.github/workflows/dev-build.yaml
vendored
@@ -1,119 +0,0 @@
|
||||
name: AnythingLLM Development Docker image (amd64/arm64)
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["4963-sidebar-selection-srcoll-into-view"] # put your current branch to create a build. Core team only.
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "cloud-deployments/*"
|
||||
- "images/**/*"
|
||||
- ".vscode/**/*"
|
||||
- "**/.env.example"
|
||||
- ".github/ISSUE_TEMPLATE/**/*"
|
||||
- ".devcontainer/**/*"
|
||||
- "embed/**/*" # Embed should be published to frontend (yarn build:publish) if any changes are introduced
|
||||
- "browser-extension/**/*" # Chrome extension is submodule
|
||||
- "server/utils/agents/aibitat/example/**/*" # Do not push new image for local dev testing of new aibitat images.
|
||||
- "extras/**/*" # Extra is just for news and other local content.
|
||||
|
||||
jobs:
|
||||
push_dev_build_to_dockerhub:
|
||||
name: Push development build image to Docker Hub
|
||||
runs-on: ubuntu-22.04-arm
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check if DockerHub build needed
|
||||
shell: bash
|
||||
run: |
|
||||
# Check if the secret for USERNAME is set (don't even check for the password)
|
||||
if [[ -z "${{ secrets.DOCKER_USERNAME }}" ]]; then
|
||||
echo "DockerHub build not needed"
|
||||
echo "enabled=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "DockerHub build needed"
|
||||
echo "enabled=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
id: dockerhub
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
version: v0.22.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
# Only login to the Docker Hub if the repo is mintplex/anythingllm, to allow for forks to build on GHCR
|
||||
if: steps.dockerhub.outputs.enabled == 'true'
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: |
|
||||
${{ steps.dockerhub.outputs.enabled == 'true' && 'mintplexlabs/anythingllm' || '' }}
|
||||
tags: |
|
||||
type=raw,value=dev
|
||||
|
||||
- name: Build and push multi-platform Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# For Docker scout there are some intermediary reported CVEs which exists outside
|
||||
# of execution content or are unreachable by an attacker but exist in image.
|
||||
# We create VEX files for these so they don't show in scout summary.
|
||||
- name: Collect known and verified CVE exceptions
|
||||
id: cve-list
|
||||
run: |
|
||||
# Collect CVEs from filenames in vex folder
|
||||
CVE_NAMES=""
|
||||
for file in ./docker/vex/*.vex.json; do
|
||||
[ -e "$file" ] || continue
|
||||
filename=$(basename "$file")
|
||||
stripped_filename=${filename%.vex.json}
|
||||
CVE_NAMES+=" $stripped_filename"
|
||||
done
|
||||
echo "CVE_EXCEPTIONS=$CVE_NAMES" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
|
||||
# About VEX attestations https://docs.docker.com/scout/explore/exceptions/
|
||||
# Justifications https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md#status-justifications
|
||||
# Fixed to use v1.15.1 of scout-cli as v1.16.0 install script is broken
|
||||
# https://github.com/docker/scout-cli
|
||||
- name: Add VEX attestations
|
||||
env:
|
||||
CVE_EXCEPTIONS: ${{ steps.cve-list.outputs.CVE_EXCEPTIONS }}
|
||||
run: |
|
||||
echo $CVE_EXCEPTIONS
|
||||
curl -sSfL https://raw.githubusercontent.com/docker/scout-cli/main/install.sh | sh -s --
|
||||
for cve in $CVE_EXCEPTIONS; do
|
||||
for tag in "${{ join(fromJSON(steps.meta.outputs.json).tags, ' ') }}"; do
|
||||
echo "Attaching VEX exception $cve to $tag"
|
||||
docker scout attestation add \
|
||||
--file "./docker/vex/$cve.vex.json" \
|
||||
--predicate-type https://openvex.dev/ns/v0.2.0 \
|
||||
$tag
|
||||
done
|
||||
done
|
||||
shell: bash
|
||||
75
.github/workflows/lint.yaml
vendored
Normal file
75
.github/workflows/lint.yaml
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
name: Lint
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "server/**/*.js"
|
||||
- "server/eslint.config.mjs"
|
||||
- "collector/**/*.js"
|
||||
- "collector/eslint.config.mjs"
|
||||
- "frontend/src/**/*.js"
|
||||
- "frontend/src/**/*.jsx"
|
||||
- "frontend/eslint.config.js"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Cache server dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: server/node_modules
|
||||
key: ${{ runner.os }}-yarn-server-${{ hashFiles('server/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-server-
|
||||
|
||||
- name: Cache frontend dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: ${{ runner.os }}-yarn-frontend-${{ hashFiles('frontend/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-frontend-
|
||||
|
||||
- name: Cache collector dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: collector/node_modules
|
||||
key: ${{ runner.os }}-yarn-collector-${{ hashFiles('collector/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-collector-
|
||||
|
||||
- name: Install server dependencies
|
||||
run: cd server && yarn install --frozen-lockfile
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && yarn install --frozen-lockfile
|
||||
|
||||
- name: Install collector dependencies
|
||||
run: cd collector && yarn install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||
SHARP_IGNORE_GLOBAL_LIBVIPS: "true"
|
||||
|
||||
- name: Lint server
|
||||
run: cd server && yarn lint:check
|
||||
|
||||
- name: Lint frontend
|
||||
run: cd frontend && yarn lint:check
|
||||
|
||||
- name: Lint collector
|
||||
run: cd collector && yarn lint:check
|
||||
5
.github/workflows/run-tests.yaml
vendored
5
.github/workflows/run-tests.yaml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Run backend tests
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
@@ -65,6 +65,9 @@ jobs:
|
||||
- name: Install collector dependencies
|
||||
if: steps.cache-collector.outputs.cache-hit != 'true'
|
||||
run: cd collector && yarn install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||
SHARP_IGNORE_GLOBAL_LIBVIPS: "true"
|
||||
|
||||
- name: Setup environment and Prisma
|
||||
run: yarn setup:envs && yarn prisma:setup
|
||||
|
||||
33
README.md
33
README.md
@@ -36,9 +36,9 @@
|
||||
👉 AnythingLLM for desktop (Mac, Windows, & Linux)! <a href="https://anythingllm.com/download" target="_blank"> Download Now</a>
|
||||
</p>
|
||||
|
||||
A full-stack application that enables you to turn any document, resource, or piece of content into context that any LLM can use as a reference during chatting. This application allows you to pick and choose which LLM or Vector Database you want to use as well as supporting multi-user management and permissions.
|
||||
Chat with your docs. Automate complex workflows with AI Agents. Hyper-configurable, multi-user ready, battle-tested—and runs locally by default with zero setup friction.
|
||||
|
||||

|
||||

|
||||
|
||||
<details>
|
||||
<summary><kbd>Watch the demo!</kbd></summary>
|
||||
@@ -49,26 +49,27 @@ A full-stack application that enables you to turn any document, resource, or pie
|
||||
|
||||
### Product Overview
|
||||
|
||||
AnythingLLM is a full-stack application where you can use commercial off-the-shelf LLMs or popular open source LLMs and vectorDB solutions to build a private ChatGPT with no compromises that you can run locally as well as host remotely and be able to chat intelligently with any documents you provide it.
|
||||
AnythingLLM is the all-in-one AI application that lets you build a private, fully-featured ChatGPT—without compromises. Connect your favorite local or cloud LLM, ingest your documents, and start chatting in minutes. Out of the box you get built-in agents, multi-user support, vector databases, and document pipelines — no extra configuration required.
|
||||
|
||||
AnythingLLM divides your documents into objects called `workspaces`. A Workspace functions a lot like a thread, but with the addition of containerization of your documents. Workspaces can share documents, but they do not talk to each other so you can keep your context for each workspace clean.
|
||||
AnythingLLM supports multiple users as well where you can control the access and experience per user without compromising the security or privacy of the instance or your intellectual property.
|
||||
|
||||
## Cool features of AnythingLLM
|
||||
|
||||
- 🆕 [**Full MCP-compatibility**](https://docs.anythingllm.com/mcp-compatibility/overview)
|
||||
- 🆕 [**No-code AI Agent builder**](https://docs.anythingllm.com/agent-flows/overview)
|
||||
- 🖼️ **Multi-modal support (both closed and open-source LLMs!)**
|
||||
- [Intelligent Skill Selection](https://docs.anythingllm.com/agent/intelligent-tool-selection) Enable **unlimited** tools for your models while reducing token usage by up to 80% per query
|
||||
- [**No-code AI Agent builder**](https://docs.anythingllm.com/agent-flows/overview)
|
||||
- [**Full MCP-compatibility**](https://docs.anythingllm.com/mcp-compatibility/overview)
|
||||
- **Multi-modal support (both closed and open-source LLMs!)**
|
||||
- [**Custom AI Agents**](https://docs.anythingllm.com/agent/custom/introduction)
|
||||
- 👤 Multi-user instance support and permissioning _Docker version only_
|
||||
- 🦾 Agents inside your workspace (browse the web, etc)
|
||||
- 💬 [Custom Embeddable Chat widget for your website](https://github.com/Mintplex-Labs/anythingllm-embed/blob/main/README.md) _Docker version only_
|
||||
- 📖 Multiple document type support (PDF, TXT, DOCX, etc)
|
||||
- Simple chat UI with Drag-n-Drop functionality and clear citations.
|
||||
- 100% Cloud deployment ready.
|
||||
- Intuitive chat UI with drag-and-drop uploads and source citations.
|
||||
- Production-ready for any cloud deployment.
|
||||
- Works with all popular [closed and open-source LLM providers](#supported-llms-embedder-models-speech-models-and-vector-databases).
|
||||
- Built-in cost & time-saving measures for managing very large documents compared to any other chat UI.
|
||||
- Built-in optimizations for large document sets—lower costs and faster responses than other chat UIs.
|
||||
- Full Developer API for custom integrations!
|
||||
- Much more...install and find out!
|
||||
- ...and much more—install in minutes and see for yourself.
|
||||
|
||||
### Supported LLMs, Embedder Models, Speech models, and Vector Databases
|
||||
|
||||
@@ -184,16 +185,6 @@ Mintplex Labs & the community maintain a number of deployment methods, scripts,
|
||||
|
||||
[Learn about documents](./server/storage/documents/DOCUMENTS.md)
|
||||
|
||||
[Learn about vector caching](./server/storage/vector-cache/VECTOR_CACHE.md)
|
||||
|
||||
## External Apps & Integrations
|
||||
|
||||
_These are apps that are not maintained by Mintplex Labs, but are compatible with AnythingLLM. A listing here is not an endorsement._
|
||||
|
||||
- [Midori AI Subsystem Manager](https://io.midori-ai.xyz/subsystem/anythingllm/) - A streamlined and efficient way to deploy AI systems using Docker container technology.
|
||||
- [Coolify](https://coolify.io/docs/services/anythingllm/) - Deploy AnythingLLM with a single click.
|
||||
- [GPTLocalhost for Microsoft Word](https://gptlocalhost.com/demo/) - A local Word Add-in for you to use AnythingLLM in Microsoft Word.
|
||||
|
||||
## Telemetry & Privacy
|
||||
|
||||
AnythingLLM by Mintplex Labs Inc contains a telemetry feature that collects anonymous usage information.
|
||||
|
||||
37
TERMS_SELF_HOSTED.md
Normal file
37
TERMS_SELF_HOSTED.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# AnythingLLM Self-Hosted: Data Privacy & Terms of Service
|
||||
|
||||
This document outlines the privacy standards, data handling procedures, and licensing terms for the self-hosted version of AnythingLLM, developed by Mintplex Labs Inc.
|
||||
|
||||
## 1. Data Sovereignty & Local-First Architecture
|
||||
AnythingLLM is designed as a **local-first** application. When utilizing the self-hosted version (Docker, Desktop, or Source):
|
||||
* **No External Access:** Mintplex Labs Inc. does not host, store, or have access to any documents, chat histories, workspace settings, or embeddings created within your instance.
|
||||
* **On-Premise Storage:** All data resides strictly on the infrastructure provisioned and managed by the user or their organization.
|
||||
* **Air-Gap Capability:** AnythingLLM can be operated in a strictly air-gapped environment with no internet connectivity, provided local LLM and Vector database providers (e.g., Ollama, LocalAI, LanceDB) are utilized.
|
||||
|
||||
## 2. Telemetry and Analytics
|
||||
To improve software performance and stability, AnythingLLM includes an optional telemetry feature.
|
||||
* **Anonymity:** Collected data is strictly anonymous and contains no Personally Identifiable Information (PII), document content, chat logs, fingerprinting data, or any other sensitive information. Purely usage based data is collected.
|
||||
* **Opt-Out:** Users may disable telemetry at any time via the **Settings** menu within the application. Once disabled, no usage data is transmitted to Mintplex Labs.
|
||||
|
||||
## 3. Third-Party Integrations
|
||||
AnythingLLM allows users to connect to external services (e.g., OpenAI, Anthropic, Pinecone).
|
||||
* **Data Transmission:** When these services are enabled, data is transmitted directly from your instance to the third-party provider.
|
||||
* **Governing Terms:** Data handled by third-party providers is subject to their respective Terms of Service and Privacy Policies. Mintplex Labs is not responsible for the data practices of these external entities.
|
||||
|
||||
_by default, AnythingLLM does **everything on-device first** - so you would have to manually configure and enable these integrations to be subject to third party terms._
|
||||
|
||||
## 4. Security & Network
|
||||
* **No "Phone Home":** Aside from [optional telemetry](https://github.com/Mintplex-Labs/anything-llm?tab=readme-ov-file#telemetry--privacy), the software does not require an external connection to Mintplex Labs servers to function.
|
||||
* **Environment Security:** The user is responsible for securing the host environment, including network firewalls, SSL/TLS encryption, and access control for the AnythingLLM instance.
|
||||
* **CDN Assets:** Out of a convience to international users, we use a hosted CDN to mirror some critical path models (eg: the default embedder and reranking ONNX models) which are not available in all regions. These models are downloaded from our CDN as a fallback, and any air-gapped installations you can either download these models manually or use another provider. Assets of these nature are downloaded once and cached in your associated local storage.
|
||||
|
||||
## 5. Licensing and Liability
|
||||
* **License:** The AnythingLLM core is provided under the **MIT License**.
|
||||
* **No Warranty:** As per the license agreement, the software is provided "as is," without warranty of any kind, express or implied, including but not limited to the warranties of merchantability or fitness for a particular purpose.
|
||||
* **Liability:** In no event shall the authors or copyright holders be liable for any claim, damages, or other liability arising from the use of the software.
|
||||
|
||||
## 6. Support and Compatibility
|
||||
While Mintplex Labs prioritizes stability and backward compatibility, the self-hosted version is used at the user's discretion. Formal Service Level Agreements (SLAs) are not provided for the standard self-hosted version unless otherwise negotiated via a separate enterprise agreement.
|
||||
|
||||
---
|
||||
*Last Updated: March 2026*
|
||||
@@ -58,7 +58,7 @@ Notes:
|
||||
```yaml
|
||||
image:
|
||||
repository: mintplexlabs/anythingllm
|
||||
tag: "1.11.1"
|
||||
tag: "1.11.2"
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
@@ -104,7 +104,7 @@ helm install my-anythingllm ./anythingllm -f values-secret.yaml
|
||||
| fullnameOverride | string | `""` | |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| image.repository | string | `"mintplexlabs/anythingllm"` | |
|
||||
| image.tag | string | `"1.11.1"` | |
|
||||
| image.tag | string | `"1.11.2"` | |
|
||||
| imagePullSecrets | list | `[]` | |
|
||||
| ingress.annotations | object | `{}` | |
|
||||
| ingress.className | string | `""` | |
|
||||
|
||||
@@ -69,7 +69,7 @@ Notes:
|
||||
```yaml
|
||||
image:
|
||||
repository: mintplexlabs/anythingllm
|
||||
tag: "1.11.1"
|
||||
tag: "1.11.2"
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
|
||||
@@ -8,7 +8,7 @@ initContainers: []
|
||||
image:
|
||||
repository: mintplexlabs/anythingllm
|
||||
pullPolicy: IfNotPresent
|
||||
tag: "1.11.1"
|
||||
tag: "1.11.2"
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
|
||||
96
collector/__tests__/utils/downloadURIToFile/index.test.js
Normal file
96
collector/__tests__/utils/downloadURIToFile/index.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const path = require("path");
|
||||
const { SUPPORTED_FILETYPE_CONVERTERS } = require("../../../utils/constants");
|
||||
const { mimeToExtension } = require("../../../utils/downloadURIToFile");
|
||||
|
||||
/**
|
||||
* Simulates the filename-building logic from downloadURIToFile
|
||||
* to verify extension inference works correctly.
|
||||
*/
|
||||
function buildFilenameWithExtension(sluggedFilename, contentType) {
|
||||
const existingExt = path.extname(sluggedFilename).toLowerCase();
|
||||
if (!SUPPORTED_FILETYPE_CONVERTERS.hasOwnProperty(existingExt)) {
|
||||
const mimeType = contentType?.toLowerCase()?.split(";")[0]?.trim();
|
||||
const inferredExt = mimeToExtension(mimeType);
|
||||
if (inferredExt) {
|
||||
return sluggedFilename + inferredExt;
|
||||
}
|
||||
}
|
||||
return sluggedFilename;
|
||||
}
|
||||
|
||||
describe("mimeToExtension", () => {
|
||||
test("returns null for invalid or unknown input", () => {
|
||||
expect(mimeToExtension(null)).toBeNull();
|
||||
expect(mimeToExtension(undefined)).toBeNull();
|
||||
expect(mimeToExtension("application/octet-stream")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns first extension from ACCEPTED_MIMES for known types", () => {
|
||||
expect(mimeToExtension("application/pdf")).toBe(".pdf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFilenameWithExtension", () => {
|
||||
test("appends .pdf when URL path has no recognized extension (arxiv case)", () => {
|
||||
// Simulates: https://arxiv.org/pdf/2307.10265
|
||||
// slugify produces something like "arxiv.org-pdf-230710265"
|
||||
const filename = "arxiv.org-pdf-230710265";
|
||||
const result = buildFilenameWithExtension(filename, "application/pdf");
|
||||
expect(result).toBe("arxiv.org-pdf-230710265.pdf");
|
||||
});
|
||||
|
||||
test("appends .pdf when URL has numeric-looking extension", () => {
|
||||
// path.extname("arxiv.org-pdf-2307.10265") => ".10265" which is not in SUPPORTED_FILETYPE_CONVERTERS
|
||||
const filename = "arxiv.org-pdf-2307.10265";
|
||||
const result = buildFilenameWithExtension(
|
||||
filename,
|
||||
"application/pdf; charset=utf-8"
|
||||
);
|
||||
expect(result).toBe("arxiv.org-pdf-2307.10265.pdf");
|
||||
});
|
||||
|
||||
test("does NOT append extension when file already has a supported extension", () => {
|
||||
const filename = "example.com-document.pdf";
|
||||
const result = buildFilenameWithExtension(filename, "application/pdf");
|
||||
expect(result).toBe("example.com-document.pdf");
|
||||
});
|
||||
|
||||
test("does NOT append extension when file has .txt extension", () => {
|
||||
const filename = "example.com-readme.txt";
|
||||
const result = buildFilenameWithExtension(filename, "text/plain");
|
||||
expect(result).toBe("example.com-readme.txt");
|
||||
});
|
||||
|
||||
test("does not append extension for unknown content type", () => {
|
||||
const filename = "example.com-binary-blob";
|
||||
const result = buildFilenameWithExtension(
|
||||
filename,
|
||||
"application/octet-stream"
|
||||
);
|
||||
expect(result).toBe("example.com-binary-blob");
|
||||
});
|
||||
|
||||
test("does not append extension when content type is null", () => {
|
||||
const filename = "example.com-unknown";
|
||||
const result = buildFilenameWithExtension(filename, null);
|
||||
expect(result).toBe("example.com-unknown");
|
||||
});
|
||||
|
||||
test("appends .docx for word document MIME type", () => {
|
||||
const filename = "sharepoint.com-documents-report";
|
||||
const result = buildFilenameWithExtension(
|
||||
filename,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
);
|
||||
expect(result).toBe("sharepoint.com-documents-report.docx");
|
||||
});
|
||||
|
||||
test("handles content type with charset parameter correctly", () => {
|
||||
const filename = "api.example.com-export-data";
|
||||
const result = buildFilenameWithExtension(
|
||||
filename,
|
||||
"text/csv; charset=utf-8"
|
||||
);
|
||||
expect(result).toBe("api.example.com-export-data.csv");
|
||||
});
|
||||
});
|
||||
38
collector/eslint.config.mjs
Normal file
38
collector/eslint.config.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import pluginPrettier from "eslint-plugin-prettier";
|
||||
import configPrettier from "eslint-config-prettier";
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
|
||||
export default defineConfig([
|
||||
{ ignores: ["__tests__/**"] },
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs}"],
|
||||
plugins: { js, prettier: pluginPrettier, "unused-imports": unusedImports },
|
||||
extends: ["js/recommended"],
|
||||
languageOptions: { globals: { ...globals.node, ...globals.browser } },
|
||||
rules: {
|
||||
...configPrettier.rules,
|
||||
"prettier/prettier": "error",
|
||||
"no-case-declarations": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"no-async-promise-executor": "off",
|
||||
"no-extra-boolean-cast": "off",
|
||||
"no-empty": "off",
|
||||
"no-unused-private-class-members": "warn",
|
||||
"no-unused-vars": "off",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
vars: "all",
|
||||
varsIgnorePattern: "^_",
|
||||
args: "after-used",
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ files: ["**/*.js"], languageOptions: { sourceType: "commonjs" } },
|
||||
]);
|
||||
@@ -1,6 +1,9 @@
|
||||
const { setDataSigner } = require("../middleware/setDataSigner");
|
||||
const { verifyPayloadIntegrity } = require("../middleware/verifyIntegrity");
|
||||
const { resolveRepoLoader, resolveRepoLoaderFunction } = require("../utils/extensions/RepoLoader");
|
||||
const {
|
||||
resolveRepoLoader,
|
||||
resolveRepoLoaderFunction,
|
||||
} = require("../utils/extensions/RepoLoader");
|
||||
const { reqBody } = require("../utils/http");
|
||||
const { validURL, validateURL } = require("../utils/url");
|
||||
const RESYNC_METHODS = require("./resync");
|
||||
@@ -15,7 +18,8 @@ function extensions(app) {
|
||||
async function (request, response) {
|
||||
try {
|
||||
const { type, options } = reqBody(request);
|
||||
if (!RESYNC_METHODS.hasOwnProperty(type)) throw new Error(`Type "${type}" is not a valid type to sync.`);
|
||||
if (!RESYNC_METHODS.hasOwnProperty(type))
|
||||
throw new Error(`Type "${type}" is not a valid type to sync.`);
|
||||
return await RESYNC_METHODS[type](options, response);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -27,17 +31,19 @@ function extensions(app) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/ext/:repo_platform-repo",
|
||||
[verifyPayloadIntegrity, setDataSigner],
|
||||
async function (request, response) {
|
||||
try {
|
||||
const loadRepo = resolveRepoLoaderFunction(request.params.repo_platform);
|
||||
const loadRepo = resolveRepoLoaderFunction(
|
||||
request.params.repo_platform
|
||||
);
|
||||
const { success, reason, data } = await loadRepo(
|
||||
reqBody(request),
|
||||
response,
|
||||
response
|
||||
);
|
||||
response.status(200).json({
|
||||
success,
|
||||
@@ -92,7 +98,9 @@ function extensions(app) {
|
||||
[verifyPayloadIntegrity],
|
||||
async function (request, response) {
|
||||
try {
|
||||
const { loadYouTubeTranscript } = require("../utils/extensions/YoutubeTranscript");
|
||||
const {
|
||||
loadYouTubeTranscript,
|
||||
} = require("../utils/extensions/YoutubeTranscript");
|
||||
const { success, reason, data } = await loadYouTubeTranscript(
|
||||
reqBody(request)
|
||||
);
|
||||
@@ -162,7 +170,9 @@ function extensions(app) {
|
||||
[verifyPayloadIntegrity, setDataSigner],
|
||||
async function (request, response) {
|
||||
try {
|
||||
const { loadAndStoreSpaces } = require("../utils/extensions/DrupalWiki");
|
||||
const {
|
||||
loadAndStoreSpaces,
|
||||
} = require("../utils/extensions/DrupalWiki");
|
||||
const { success, reason, data } = await loadAndStoreSpaces(
|
||||
reqBody(request),
|
||||
response
|
||||
@@ -208,7 +218,9 @@ function extensions(app) {
|
||||
[verifyPayloadIntegrity, setDataSigner],
|
||||
async function (request, response) {
|
||||
try {
|
||||
const { loadPaperlessNgx } = require("../utils/extensions/PaperlessNgx");
|
||||
const {
|
||||
loadPaperlessNgx,
|
||||
} = require("../utils/extensions/PaperlessNgx");
|
||||
const result = await loadPaperlessNgx(reqBody(request), response);
|
||||
response.status(200).json(result);
|
||||
} catch (e) {
|
||||
@@ -224,4 +236,4 @@ function extensions(app) {
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = extensions;
|
||||
module.exports = extensions;
|
||||
|
||||
@@ -6,9 +6,9 @@ const { getLinkText } = require("../../processLink");
|
||||
* @param {import("../../middleware/setDataSigner").ResponseWithSigner} response
|
||||
*/
|
||||
async function resyncLink({ link }, response) {
|
||||
if (!link) throw new Error('Invalid link provided');
|
||||
if (!link) throw new Error("Invalid link provided");
|
||||
try {
|
||||
const { success, content = null } = await getLinkText(link);
|
||||
const { success, content = null, reason } = await getLinkText(link);
|
||||
if (!success) throw new Error(`Failed to sync link content. ${reason}`);
|
||||
response.status(200).json({ success, content });
|
||||
} catch (e) {
|
||||
@@ -28,11 +28,16 @@ async function resyncLink({ link }, response) {
|
||||
* @param {import("../../middleware/setDataSigner").ResponseWithSigner} response
|
||||
*/
|
||||
async function resyncYouTube({ link }, response) {
|
||||
if (!link) throw new Error('Invalid link provided');
|
||||
if (!link) throw new Error("Invalid link provided");
|
||||
try {
|
||||
const { fetchVideoTranscriptContent } = require("../../utils/extensions/YoutubeTranscript");
|
||||
const { success, reason, content } = await fetchVideoTranscriptContent({ url: link });
|
||||
if (!success) throw new Error(`Failed to sync YouTube video transcript. ${reason}`);
|
||||
const {
|
||||
fetchVideoTranscriptContent,
|
||||
} = require("../../utils/extensions/YoutubeTranscript");
|
||||
const { success, reason, content } = await fetchVideoTranscriptContent({
|
||||
url: link,
|
||||
});
|
||||
if (!success)
|
||||
throw new Error(`Failed to sync YouTube video transcript. ${reason}`);
|
||||
response.status(200).json({ success, content });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -50,23 +55,26 @@ async function resyncYouTube({ link }, response) {
|
||||
* @param {import("../../middleware/setDataSigner").ResponseWithSigner} response
|
||||
*/
|
||||
async function resyncConfluence({ chunkSource }, response) {
|
||||
if (!chunkSource) throw new Error('Invalid source property provided');
|
||||
if (!chunkSource) throw new Error("Invalid source property provided");
|
||||
try {
|
||||
// Confluence data is `payload` encrypted. So we need to expand its
|
||||
// encrypted payload back into query params so we can reFetch the page with same access token/params.
|
||||
const source = response.locals.encryptionWorker.expandPayload(chunkSource);
|
||||
const { fetchConfluencePage } = require("../../utils/extensions/Confluence");
|
||||
const {
|
||||
fetchConfluencePage,
|
||||
} = require("../../utils/extensions/Confluence");
|
||||
const { success, reason, content } = await fetchConfluencePage({
|
||||
pageUrl: `https:${source.pathname}`, // need to add back the real protocol
|
||||
baseUrl: source.searchParams.get('baseUrl'),
|
||||
spaceKey: source.searchParams.get('spaceKey'),
|
||||
accessToken: source.searchParams.get('token'),
|
||||
username: source.searchParams.get('username'),
|
||||
cloud: source.searchParams.get('cloud') === 'true',
|
||||
bypassSSL: source.searchParams.get('bypassSSL') === 'true',
|
||||
baseUrl: source.searchParams.get("baseUrl"),
|
||||
spaceKey: source.searchParams.get("spaceKey"),
|
||||
accessToken: source.searchParams.get("token"),
|
||||
username: source.searchParams.get("username"),
|
||||
cloud: source.searchParams.get("cloud") === "true",
|
||||
bypassSSL: source.searchParams.get("bypassSSL") === "true",
|
||||
});
|
||||
|
||||
if (!success) throw new Error(`Failed to sync Confluence page content. ${reason}`);
|
||||
if (!success)
|
||||
throw new Error(`Failed to sync Confluence page content. ${reason}`);
|
||||
response.status(200).json({ success, content });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -84,20 +92,23 @@ async function resyncConfluence({ chunkSource }, response) {
|
||||
* @param {import("../../middleware/setDataSigner").ResponseWithSigner} response
|
||||
*/
|
||||
async function resyncGithub({ chunkSource }, response) {
|
||||
if (!chunkSource) throw new Error('Invalid source property provided');
|
||||
if (!chunkSource) throw new Error("Invalid source property provided");
|
||||
try {
|
||||
// Github file data is `payload` encrypted (might contain PAT). So we need to expand its
|
||||
// encrypted payload back into query params so we can reFetch the page with same access token/params.
|
||||
const source = response.locals.encryptionWorker.expandPayload(chunkSource);
|
||||
const { fetchGithubFile } = require("../../utils/extensions/RepoLoader/GithubRepo");
|
||||
const {
|
||||
fetchGithubFile,
|
||||
} = require("../../utils/extensions/RepoLoader/GithubRepo");
|
||||
const { success, reason, content } = await fetchGithubFile({
|
||||
repoUrl: `https:${source.pathname}`, // need to add back the real protocol
|
||||
branch: source.searchParams.get('branch'),
|
||||
accessToken: source.searchParams.get('pat'),
|
||||
sourceFilePath: source.searchParams.get('path'),
|
||||
branch: source.searchParams.get("branch"),
|
||||
accessToken: source.searchParams.get("pat"),
|
||||
sourceFilePath: source.searchParams.get("path"),
|
||||
});
|
||||
|
||||
if (!success) throw new Error(`Failed to sync GitHub file content. ${reason}`);
|
||||
if (!success)
|
||||
throw new Error(`Failed to sync GitHub file content. ${reason}`);
|
||||
response.status(200).json({ success, content });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -108,7 +119,6 @@ async function resyncGithub({ chunkSource }, response) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetches the content of a specific DrupalWiki page via its chunkSource.
|
||||
* Returns the content as a text string of the page in question and only that page.
|
||||
@@ -116,16 +126,16 @@ async function resyncGithub({ chunkSource }, response) {
|
||||
* @param {import("../../middleware/setDataSigner").ResponseWithSigner} response
|
||||
*/
|
||||
async function resyncDrupalWiki({ chunkSource }, response) {
|
||||
if (!chunkSource) throw new Error('Invalid source property provided');
|
||||
if (!chunkSource) throw new Error("Invalid source property provided");
|
||||
try {
|
||||
// DrupalWiki data is `payload` encrypted. So we need to expand its
|
||||
// encrypted payload back into query params so we can reFetch the page with same access token/params.
|
||||
const source = response.locals.encryptionWorker.expandPayload(chunkSource);
|
||||
const { loadPage } = require("../../utils/extensions/DrupalWiki");
|
||||
const { success, reason, content } = await loadPage({
|
||||
baseUrl: source.searchParams.get('baseUrl'),
|
||||
pageId: source.searchParams.get('pageId'),
|
||||
accessToken: source.searchParams.get('accessToken'),
|
||||
baseUrl: source.searchParams.get("baseUrl"),
|
||||
pageId: source.searchParams.get("pageId"),
|
||||
accessToken: source.searchParams.get("accessToken"),
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
@@ -153,18 +163,20 @@ async function resyncDrupalWiki({ chunkSource }, response) {
|
||||
* @param {import("../../middleware/setDataSigner").ResponseWithSigner} response
|
||||
*/
|
||||
async function resyncPaperlessNgx({ chunkSource }, response) {
|
||||
if (!chunkSource) throw new Error('Invalid source property provided');
|
||||
if (!chunkSource) throw new Error("Invalid source property provided");
|
||||
try {
|
||||
const source = response.locals.encryptionWorker.expandPayload(chunkSource);
|
||||
const { PaperlessNgxLoader } = require("../../utils/extensions/PaperlessNgx/PaperlessNgxLoader");
|
||||
const {
|
||||
PaperlessNgxLoader,
|
||||
} = require("../../utils/extensions/PaperlessNgx/PaperlessNgxLoader");
|
||||
const loader = new PaperlessNgxLoader({
|
||||
baseUrl: source.searchParams.get('baseUrl'),
|
||||
apiToken: source.searchParams.get('token'),
|
||||
baseUrl: source.searchParams.get("baseUrl"),
|
||||
apiToken: source.searchParams.get("token"),
|
||||
});
|
||||
const documentId = source.pathname.split('//')[1];
|
||||
const documentId = source.pathname.split("//")[1];
|
||||
const content = await loader.fetchDocumentContent(documentId);
|
||||
|
||||
if (!content) throw new Error('Failed to fetch document content');
|
||||
if (!content) throw new Error("Failed to fetch document content");
|
||||
response.status(200).json({ success: true, content });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -182,4 +194,4 @@ module.exports = {
|
||||
github: resyncGithub,
|
||||
drupalwiki: resyncDrupalWiki,
|
||||
"paperless-ngx": resyncPaperlessNgx,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,6 +86,7 @@ app.post(
|
||||
} = await processSingleFile(targetFilename, {
|
||||
...options,
|
||||
parseOnly: true,
|
||||
absolutePath: options.absolutePath || null,
|
||||
});
|
||||
response
|
||||
.status(200)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const { EncryptionWorker } = require("../utils/EncryptionWorker");
|
||||
const { CommunicationKey } = require("../utils/comKey");
|
||||
|
||||
/**
|
||||
/**
|
||||
* Express Response Object interface with defined encryptionWorker attached to locals property.
|
||||
* @typedef {import("express").Response & import("express").Response['locals'] & {encryptionWorker: EncryptionWorker} } ResponseWithSigner
|
||||
*/
|
||||
*/
|
||||
|
||||
// You can use this middleware to assign the EncryptionWorker to the response locals
|
||||
// property so that if can be used to encrypt/decrypt arbitrary data via response object.
|
||||
@@ -20,15 +20,18 @@ const { CommunicationKey } = require("../utils/comKey");
|
||||
// collector out into its own service this would still work without SSL/TLS.
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import("express").Request} request
|
||||
* @param {import("express").Response} response
|
||||
* @param {import("express").NextFunction} next
|
||||
*
|
||||
* @param {import("express").Request} request
|
||||
* @param {import("express").Response} response
|
||||
* @param {import("express").NextFunction} next
|
||||
*/
|
||||
function setDataSigner(request, response, next) {
|
||||
const comKey = new CommunicationKey();
|
||||
const encryptedPayloadSigner = request.header("X-Payload-Signer");
|
||||
if (!encryptedPayloadSigner) console.log('Failed to find signed-payload to set encryption worker! Encryption calls will fail.');
|
||||
if (!encryptedPayloadSigner)
|
||||
console.log(
|
||||
"Failed to find signed-payload to set encryption worker! Encryption calls will fail."
|
||||
);
|
||||
|
||||
const decryptedPayloadSignerKey = comKey.decrypt(encryptedPayloadSigner);
|
||||
const encryptionWorker = new EncryptionWorker(decryptedPayloadSignerKey);
|
||||
@@ -37,5 +40,5 @@ function setDataSigner(request, response, next) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setDataSigner
|
||||
}
|
||||
setDataSigner,
|
||||
};
|
||||
|
||||
@@ -5,22 +5,28 @@ const runtimeSettings = new RuntimeSettings();
|
||||
function verifyPayloadIntegrity(request, response, next) {
|
||||
const comKey = new CommunicationKey();
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
comKey.log('verifyPayloadIntegrity is skipped in development.');
|
||||
comKey.log("verifyPayloadIntegrity is skipped in development.");
|
||||
runtimeSettings.parseOptionsFromRequest(request);
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const signature = request.header("X-Integrity");
|
||||
if (!signature) return response.status(400).json({ msg: 'Failed integrity signature check.' })
|
||||
if (!signature)
|
||||
return response
|
||||
.status(400)
|
||||
.json({ msg: "Failed integrity signature check." });
|
||||
|
||||
const validSignedPayload = comKey.verify(signature, request.body);
|
||||
if (!validSignedPayload) return response.status(400).json({ msg: 'Failed integrity signature check.' });
|
||||
if (!validSignedPayload)
|
||||
return response
|
||||
.status(400)
|
||||
.json({ msg: "Failed integrity signature check." });
|
||||
|
||||
runtimeSettings.parseOptionsFromRequest(request);
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
verifyPayloadIntegrity
|
||||
}
|
||||
verifyPayloadIntegrity,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development nodemon --ignore hotdir --ignore storage --trace-warnings index.js",
|
||||
"start": "cross-env NODE_ENV=production node index.js",
|
||||
"lint": "yarn prettier --ignore-path ../.prettierignore --write ./processSingleFile ./processLink ./utils index.js"
|
||||
"lint": "eslint --fix .",
|
||||
"lint:check": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/community": "^0.2.23",
|
||||
@@ -49,7 +50,13 @@
|
||||
"youtubei.js": "^9.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-unused-imports": "^4.0.0",
|
||||
"globals": "^17.4.0",
|
||||
"nodemon": "^2.0.22",
|
||||
"prettier": "^2.4.1"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,16 @@ const { downloadURIToFile } = require("../../utils/downloadURIToFile");
|
||||
const { ACCEPTED_MIMES } = require("../../utils/constants");
|
||||
const { validYoutubeVideoUrl } = require("../../utils/url");
|
||||
|
||||
/**
|
||||
* Parse a Content-Type header value and return the MIME type without charset or other parameters.
|
||||
* @param {string|null} contentTypeHeader - The raw Content-Type header value
|
||||
* @returns {string|null} - The MIME type (e.g., "application/pdf") or null
|
||||
*/
|
||||
function parseContentType(contentTypeHeader) {
|
||||
if (!contentTypeHeader) return null;
|
||||
return contentTypeHeader.toLowerCase().split(";")[0].trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content type of a resource
|
||||
* - Sends a HEAD request to the URL and returns the Content-Type header with a 5 second timeout
|
||||
@@ -34,8 +44,9 @@ async function getContentTypeFromURL(url) {
|
||||
contentType: null,
|
||||
};
|
||||
|
||||
const contentType = res.headers.get("Content-Type")?.toLowerCase();
|
||||
const contentTypeWithoutCharset = contentType?.split(";")[0].trim();
|
||||
const contentTypeWithoutCharset = parseContentType(
|
||||
res.headers.get("Content-Type")
|
||||
);
|
||||
if (!contentTypeWithoutCharset)
|
||||
return {
|
||||
success: false,
|
||||
@@ -171,6 +182,7 @@ async function processAsFile({ uri, saveAsDocument = true }) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseContentType,
|
||||
returnResult,
|
||||
getContentTypeFromURL,
|
||||
determineContentType,
|
||||
|
||||
@@ -3,11 +3,11 @@ const { writeToServerDocuments } = require("../utils/files");
|
||||
const { tokenizeString } = require("../utils/tokenizer");
|
||||
const { default: slugify } = require("slugify");
|
||||
|
||||
// Will remove the last .extension from the input
|
||||
// Will remove the last .extension from the input
|
||||
// and stringify the input + move to lowercase.
|
||||
function stripAndSlug(input) {
|
||||
if (!input.includes('.')) return slugify(input, { lower: true });
|
||||
return slugify(input.split('.').slice(0, -1).join('-'), { lower: true })
|
||||
if (!input.includes(".")) return slugify(input, { lower: true });
|
||||
return slugify(input.split(".").slice(0, -1).join("-"), { lower: true });
|
||||
}
|
||||
|
||||
const METADATA_KEYS = {
|
||||
@@ -17,22 +17,34 @@ const METADATA_KEYS = {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
validUrl = ["https:", "http:"].includes(u.protocol);
|
||||
} catch { }
|
||||
} catch {}
|
||||
|
||||
if (validUrl) return `web://${url.toLowerCase()}.website`;
|
||||
return `file://${stripAndSlug(title)}.txt`;
|
||||
},
|
||||
title: ({ title }) => `${stripAndSlug(title)}.txt`,
|
||||
docAuthor: ({ docAuthor }) => { return typeof docAuthor === 'string' ? docAuthor : 'no author specified' },
|
||||
description: ({ description }) => { return typeof description === 'string' ? description : 'no description found' },
|
||||
docSource: ({ docSource }) => { return typeof docSource === 'string' ? docSource : 'no source set' },
|
||||
chunkSource: ({ chunkSource, title }) => { return typeof chunkSource === 'string' ? chunkSource : `${stripAndSlug(title)}.txt` },
|
||||
docAuthor: ({ docAuthor }) => {
|
||||
return typeof docAuthor === "string" ? docAuthor : "no author specified";
|
||||
},
|
||||
description: ({ description }) => {
|
||||
return typeof description === "string"
|
||||
? description
|
||||
: "no description found";
|
||||
},
|
||||
docSource: ({ docSource }) => {
|
||||
return typeof docSource === "string" ? docSource : "no source set";
|
||||
},
|
||||
chunkSource: ({ chunkSource, title }) => {
|
||||
return typeof chunkSource === "string"
|
||||
? chunkSource
|
||||
: `${stripAndSlug(title)}.txt`;
|
||||
},
|
||||
published: ({ published }) => {
|
||||
if (isNaN(Number(published))) return new Date().toLocaleString();
|
||||
return new Date(Number(published)).toLocaleString()
|
||||
return new Date(Number(published)).toLocaleString();
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function processRawText(textContent, metadata) {
|
||||
console.log(`-- Working Raw Text doc ${metadata.title} --`);
|
||||
@@ -62,8 +74,10 @@ async function processRawText(textContent, metadata) {
|
||||
data,
|
||||
filename: `raw-${stripAndSlug(metadata.title)}-${data.id}`,
|
||||
});
|
||||
console.log(`[SUCCESS]: Raw text and metadata saved & ready for embedding.\n`);
|
||||
console.log(
|
||||
`[SUCCESS]: Raw text and metadata saved & ready for embedding.\n`
|
||||
);
|
||||
return { success: true, reason: null, documents: [document] };
|
||||
}
|
||||
|
||||
module.exports = { processRawText }
|
||||
module.exports = { processRawText };
|
||||
|
||||
@@ -32,7 +32,7 @@ async function asAudio({
|
||||
|
||||
if (!!error) {
|
||||
console.error(`Error encountered for parsing of ${filename}.`);
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
return {
|
||||
success: false,
|
||||
reason: error,
|
||||
@@ -42,7 +42,7 @@ async function asAudio({
|
||||
|
||||
if (!content?.length) {
|
||||
console.error(`Resulting text content was empty for ${filename}.`);
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
return {
|
||||
success: false,
|
||||
reason: `No text content found in ${filename}.`,
|
||||
@@ -69,7 +69,7 @@ async function asAudio({
|
||||
filename: `${slugify(filename)}-${data.id}`,
|
||||
options: { parseOnly: options.parseOnly },
|
||||
});
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
console.log(
|
||||
`[SUCCESS]: ${filename} transcribed, converted & ready for embedding.\n`
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ async function asDocX({
|
||||
|
||||
if (!pageContent.length) {
|
||||
console.error(`Resulting text content was empty for ${filename}.`);
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
return {
|
||||
success: false,
|
||||
reason: `No text content found in ${filename}.`,
|
||||
@@ -55,7 +55,7 @@ async function asDocX({
|
||||
filename: `${slugify(filename)}-${data.id}`,
|
||||
options: { parseOnly: options.parseOnly },
|
||||
});
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
console.log(`[SUCCESS]: ${filename} converted & ready for embedding.\n`);
|
||||
return { success: true, reason: null, documents: [document] };
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ async function asEPub({
|
||||
|
||||
if (!content?.length) {
|
||||
console.error(`Resulting text content was empty for ${filename}.`);
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
return {
|
||||
success: false,
|
||||
reason: `No text content found in ${filename}.`,
|
||||
@@ -53,7 +53,7 @@ async function asEPub({
|
||||
filename: `${slugify(filename)}-${data.id}`,
|
||||
options: { parseOnly: options.parseOnly },
|
||||
});
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
console.log(`[SUCCESS]: ${filename} converted & ready for embedding.\n`);
|
||||
return { success: true, reason: null, documents: [document] };
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ async function asImage({
|
||||
|
||||
if (!content?.length) {
|
||||
console.error(`Resulting text content was empty for ${filename}.`);
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
return {
|
||||
success: false,
|
||||
reason: `No text content found in ${filename}.`,
|
||||
@@ -48,7 +48,7 @@ async function asImage({
|
||||
filename: `${slugify(filename)}-${data.id}`,
|
||||
options: { parseOnly: options.parseOnly },
|
||||
});
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
console.log(`[SUCCESS]: ${filename} converted & ready for embedding.\n`);
|
||||
return { success: true, reason: null, documents: [document] };
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ async function asMbox({
|
||||
|
||||
if (!mails.length) {
|
||||
console.error(`Resulting mail items was empty for ${filename}.`);
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
return {
|
||||
success: false,
|
||||
reason: `No mail items found in ${filename}.`,
|
||||
@@ -73,7 +73,7 @@ async function asMbox({
|
||||
documents.push(document);
|
||||
}
|
||||
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
console.log(
|
||||
`[SUCCESS]: ${filename} messages converted & ready for embedding.\n`
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ async function asOfficeMime({
|
||||
|
||||
if (!content.length) {
|
||||
console.error(`Resulting text content was empty for ${filename}.`);
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
return {
|
||||
success: false,
|
||||
reason: `No text content found in ${filename}.`,
|
||||
@@ -51,7 +51,7 @@ async function asOfficeMime({
|
||||
filename: `${slugify(filename)}-${data.id}`,
|
||||
options: { parseOnly: options.parseOnly },
|
||||
});
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
console.log(`[SUCCESS]: ${filename} converted & ready for embedding.\n`);
|
||||
return { success: true, reason: null, documents: [document] };
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ async function asPdf({
|
||||
|
||||
if (!pageContent.length) {
|
||||
console.error(`[asPDF] Resulting text content was empty for ${filename}.`);
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
return {
|
||||
success: false,
|
||||
reason: `No text content found in ${filename}.`,
|
||||
@@ -78,7 +78,7 @@ async function asPdf({
|
||||
filename: `${slugify(filename)}-${data.id}`,
|
||||
options: { parseOnly: options.parseOnly },
|
||||
});
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
console.log(`[SUCCESS]: ${filename} converted & ready for embedding.\n`);
|
||||
return { success: true, reason: null, documents: [document] };
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ async function asTxt({
|
||||
|
||||
if (!content?.length) {
|
||||
console.error(`Resulting text content was empty for ${filename}.`);
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
return {
|
||||
success: false,
|
||||
reason: `No text content found in ${filename}.`,
|
||||
@@ -51,7 +51,7 @@ async function asTxt({
|
||||
filename: `${slugify(filename)}-${data.id}`,
|
||||
options: { parseOnly: options.parseOnly },
|
||||
});
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
console.log(`[SUCCESS]: ${filename} converted & ready for embedding.\n`);
|
||||
return { success: true, reason: null, documents: [document] };
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ async function asXlsx({
|
||||
documents: [],
|
||||
};
|
||||
} finally {
|
||||
trashFile(fullFilePath);
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
}
|
||||
|
||||
if (documents.length === 0) {
|
||||
|
||||
@@ -17,15 +17,21 @@ const RESERVED_FILES = ["__HOTDIR__.md"];
|
||||
* @param {string} targetFilename - The filename to process
|
||||
* @param {Object} options - The options for the file processing
|
||||
* @param {boolean} options.parseOnly - If true, the file will not be saved as a document even when `writeToServerDocuments` is called in the handler. Must be explicitly set to true to use.
|
||||
* @param {string} options.absolutePath - If provided, use this absolute path instead of resolving relative to WATCH_DIRECTORY. For internal use only.
|
||||
* @param {Object} metadata - The metadata for the file processing
|
||||
* @returns {Promise<{success: boolean, reason: string, documents: Object[]}>} - The documents from the file processing
|
||||
*/
|
||||
async function processSingleFile(targetFilename, options = {}, metadata = {}) {
|
||||
const fullFilePath = path.resolve(
|
||||
WATCH_DIRECTORY,
|
||||
normalizePath(targetFilename)
|
||||
const fullFilePath = normalizePath(
|
||||
options.absolutePath || path.resolve(WATCH_DIRECTORY, targetFilename)
|
||||
);
|
||||
if (!isWithin(path.resolve(WATCH_DIRECTORY), fullFilePath))
|
||||
|
||||
// If absolute path is not provided, check if the file is within the watch directory
|
||||
// to prevent unauthorized paths from being processed.
|
||||
if (
|
||||
!options.absolutePath &&
|
||||
!isWithin(path.resolve(WATCH_DIRECTORY), fullFilePath)
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
reason: "Filename is a not a valid path to process.",
|
||||
@@ -38,6 +44,7 @@ async function processSingleFile(targetFilename, options = {}, metadata = {}) {
|
||||
reason: "Filename is a reserved filename and cannot be processed.",
|
||||
documents: [],
|
||||
};
|
||||
|
||||
if (!fs.existsSync(fullFilePath))
|
||||
return {
|
||||
success: false,
|
||||
@@ -62,7 +69,8 @@ async function processSingleFile(targetFilename, options = {}, metadata = {}) {
|
||||
);
|
||||
processFileAs = ".txt";
|
||||
} else {
|
||||
trashFile(fullFilePath);
|
||||
// If absolute path is provided, do NOT trash the file since it is a user provided path.
|
||||
if (!options.absolutePath) trashFile(fullFilePath);
|
||||
return {
|
||||
success: false,
|
||||
reason: `File extension ${fileExtension} not supported for parsing and cannot be assumed as text file type.`,
|
||||
|
||||
@@ -267,6 +267,7 @@ class OCRLoader {
|
||||
this.log(`Error: ${e.message}`);
|
||||
return null;
|
||||
} finally {
|
||||
//eslint-disable-next-line
|
||||
if (!worker) return;
|
||||
await worker.terminate();
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ const ACCEPTED_MIMES = {
|
||||
|
||||
"audio/wav": [".wav"],
|
||||
"audio/mpeg": [".mp3"],
|
||||
"audio/ogg": [".ogg", ".oga"],
|
||||
"audio/opus": [".opus"],
|
||||
"audio/mp4": [".m4a"],
|
||||
"audio/x-m4a": [".m4a"],
|
||||
"audio/webm": [".webm"],
|
||||
|
||||
"video/mp4": [".mp4"],
|
||||
"video/mpeg": [".mpeg"],
|
||||
@@ -68,6 +73,11 @@ const SUPPORTED_FILETYPE_CONVERTERS = {
|
||||
".wav": "./convert/asAudio.js",
|
||||
".mp4": "./convert/asAudio.js",
|
||||
".mpeg": "./convert/asAudio.js",
|
||||
".ogg": "./convert/asAudio.js",
|
||||
".oga": "./convert/asAudio.js",
|
||||
".opus": "./convert/asAudio.js",
|
||||
".m4a": "./convert/asAudio.js",
|
||||
".webm": "./convert/asAudio.js",
|
||||
|
||||
".png": "./convert/asImage.js",
|
||||
".jpg": "./convert/asImage.js",
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
const { WATCH_DIRECTORY } = require("../constants");
|
||||
const { WATCH_DIRECTORY, ACCEPTED_MIMES } = require("../constants");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { pipeline } = require("stream/promises");
|
||||
const { validURL } = require("../url");
|
||||
const { default: slugify } = require("slugify");
|
||||
|
||||
// Add a custom slugify extension for slashing to handle URLs with paths.
|
||||
slugify.extend({ "/": "-" });
|
||||
|
||||
/**
|
||||
* Maps a MIME type to the preferred file extension using ACCEPTED_MIMES.
|
||||
* Returns null if the MIME type is not recognized or if there are no possible extensions.
|
||||
* @param {string} mimeType - The MIME type to resolve (e.g., "application/pdf")
|
||||
* @returns {string|null} - The file extension (e.g., ".pdf") or null
|
||||
*/
|
||||
function mimeToExtension(mimeType) {
|
||||
if (!mimeType || !ACCEPTED_MIMES.hasOwnProperty(mimeType)) return null;
|
||||
const possibleExtensions = ACCEPTED_MIMES[mimeType] ?? [];
|
||||
if (possibleExtensions.length === 0) return null;
|
||||
return possibleExtensions[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file to the hotdir
|
||||
* @param {string} url - The URL of the file to download
|
||||
@@ -33,10 +49,29 @@ async function downloadURIToFile(url, maxTimeout = 10_000) {
|
||||
.finally(() => clearTimeout(timeout));
|
||||
|
||||
const urlObj = new URL(url);
|
||||
const filename = `${urlObj.hostname}-${slugify(
|
||||
urlObj.pathname.replace(/\//g, "-"),
|
||||
{ lower: true }
|
||||
)}`;
|
||||
const sluggedPath = slugify(urlObj.pathname, { lower: true });
|
||||
let filename = `${urlObj.hostname}-${sluggedPath}`;
|
||||
|
||||
const existingExt = path.extname(filename).toLowerCase();
|
||||
const { SUPPORTED_FILETYPE_CONVERTERS } = require("../constants");
|
||||
|
||||
// If the filename does not already have a supported file extension,
|
||||
// try to infer one from the response Content-Type header.
|
||||
// This handles URLs like https://arxiv.org/pdf/2307.10265 where the
|
||||
// path has no explicit extension but the server responds with
|
||||
// Content-Type: application/pdf.
|
||||
if (!SUPPORTED_FILETYPE_CONVERTERS.hasOwnProperty(existingExt)) {
|
||||
const { parseContentType } = require("../../processLink/helpers");
|
||||
const contentType = parseContentType(res.headers.get("Content-Type"));
|
||||
const inferredExt = mimeToExtension(contentType);
|
||||
if (inferredExt) {
|
||||
console.log(
|
||||
`[Collector] URL path has no recognized extension. Inferred ${inferredExt} from Content-Type: ${contentType}`
|
||||
);
|
||||
filename += inferredExt;
|
||||
}
|
||||
}
|
||||
|
||||
const localFilePath = path.join(WATCH_DIRECTORY, filename);
|
||||
const writeStream = fs.createWriteStream(localFilePath);
|
||||
await pipeline(res.body, writeStream);
|
||||
@@ -51,4 +86,5 @@ async function downloadURIToFile(url, maxTimeout = 10_000) {
|
||||
|
||||
module.exports = {
|
||||
downloadURIToFile,
|
||||
mimeToExtension,
|
||||
};
|
||||
|
||||
@@ -131,7 +131,9 @@ class ConfluencePagesLoader {
|
||||
/\n{3,}/g,
|
||||
"\n\n"
|
||||
);
|
||||
const pageUrl = `${this.baseUrl}/spaces/${this.spaceKey}/pages/${page.id}`;
|
||||
const pageUrl = `${this.baseUrl}${this.cloud ? "/wiki" : ""}/spaces/${
|
||||
this.spaceKey
|
||||
}/pages/${page.id}`;
|
||||
|
||||
return {
|
||||
pageContent: textWithPreservedStructure,
|
||||
|
||||
@@ -238,7 +238,7 @@ function validBaseUrl(baseUrl) {
|
||||
try {
|
||||
new URL(baseUrl);
|
||||
return true;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@ class DrupalWiki {
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
#processPageBody({ body, url, title, lastModified }) {
|
||||
#processPageBody({ body, title }) {
|
||||
const textContent = body.trim() !== "" ? body : title;
|
||||
|
||||
const plainTextContent = htmlToText(textContent, {
|
||||
|
||||
@@ -87,7 +87,7 @@ async function loadPage({ baseUrl, pageId, accessToken }) {
|
||||
reason: null,
|
||||
content: page.processedBody,
|
||||
};
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
reason: `Failed (re)-fetching DrupalWiki page ${pageId} form ${baseUrl}}`,
|
||||
|
||||
@@ -53,10 +53,12 @@ class GitLabRepoLoader {
|
||||
|
||||
#validGitlabUrl() {
|
||||
const validPatterns = [
|
||||
//eslint-disable-next-line
|
||||
/https:\/\/gitlab\.com\/(?<author>[^\/]+)\/(?<project>.*)/,
|
||||
// This should even match the regular hosted URL, but we may want to know
|
||||
// if this was a hosted GitLab (above) or a self-hosted (below) instance
|
||||
// since the API interface could be different.
|
||||
//eslint-disable-next-line
|
||||
/(http|https):\/\/[^\/]+\/(?<author>[^\/]+)\/(?<project>.*)/,
|
||||
];
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ function isKnownTextMime(filepath) {
|
||||
if (mimeLib.nonTextTypes.includes(type))
|
||||
return { valid: false, reason: "non_text_mime" };
|
||||
return { valid: true, reason: "valid_mime" };
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return { valid: false, reason: "generic" };
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ function parseableAsText(filepath) {
|
||||
|
||||
const content = buffer.subarray(0, bytesRead).toString("utf8");
|
||||
const nullCount = (content.match(/\0/g) || []).length;
|
||||
//eslint-disable-next-line
|
||||
const controlCount = (content.match(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g) || [])
|
||||
.length;
|
||||
|
||||
@@ -211,6 +212,7 @@ function normalizePath(filepath = "") {
|
||||
|
||||
function sanitizeFileName(fileName) {
|
||||
if (!fileName) return fileName;
|
||||
//eslint-disable-next-line
|
||||
return fileName.replace(/[<>:"\/\\|?*]/g, "");
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function validBaseUrl(baseUrl) {
|
||||
try {
|
||||
new URL(baseUrl);
|
||||
return true;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,74 @@
|
||||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@eslint-community/eslint-utils@^4.8.0":
|
||||
version "4.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595"
|
||||
integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==
|
||||
dependencies:
|
||||
eslint-visitor-keys "^3.4.3"
|
||||
|
||||
"@eslint-community/regexpp@^4.12.1":
|
||||
version "4.12.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
|
||||
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
|
||||
|
||||
"@eslint/config-array@^0.21.1":
|
||||
version "0.21.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.1.tgz#7d1b0060fea407f8301e932492ba8c18aff29713"
|
||||
integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==
|
||||
dependencies:
|
||||
"@eslint/object-schema" "^2.1.7"
|
||||
debug "^4.3.1"
|
||||
minimatch "^3.1.2"
|
||||
|
||||
"@eslint/config-helpers@^0.4.2":
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz#1bd006ceeb7e2e55b2b773ab318d300e1a66aeda"
|
||||
integrity sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==
|
||||
dependencies:
|
||||
"@eslint/core" "^0.17.0"
|
||||
|
||||
"@eslint/core@^0.17.0":
|
||||
version "0.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.17.0.tgz#77225820413d9617509da9342190a2019e78761c"
|
||||
integrity sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==
|
||||
dependencies:
|
||||
"@types/json-schema" "^7.0.15"
|
||||
|
||||
"@eslint/eslintrc@^3.3.1":
|
||||
version "3.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.4.tgz#e402b1920f7c1f5a15342caa432b1348cacbb641"
|
||||
integrity sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==
|
||||
dependencies:
|
||||
ajv "^6.14.0"
|
||||
debug "^4.3.2"
|
||||
espree "^10.0.1"
|
||||
globals "^14.0.0"
|
||||
ignore "^5.2.0"
|
||||
import-fresh "^3.2.1"
|
||||
js-yaml "^4.1.1"
|
||||
minimatch "^3.1.3"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
"@eslint/js@9.39.3", "@eslint/js@^9.0.0":
|
||||
version "9.39.3"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.3.tgz#c6168736c7e0c43ead49654ed06a4bcb3833363d"
|
||||
integrity sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==
|
||||
|
||||
"@eslint/object-schema@^2.1.7":
|
||||
version "2.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.7.tgz#6e2126a1347e86a4dedf8706ec67ff8e107ebbad"
|
||||
integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==
|
||||
|
||||
"@eslint/plugin-kit@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz#9779e3fd9b7ee33571a57435cf4335a1794a6cb2"
|
||||
integrity sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==
|
||||
dependencies:
|
||||
"@eslint/core" "^0.17.0"
|
||||
levn "^0.4.1"
|
||||
|
||||
"@fastify/busboy@^2.0.0":
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d"
|
||||
@@ -62,6 +130,29 @@
|
||||
resolved "https://registry.yarnpkg.com/@huggingface/jinja/-/jinja-0.2.2.tgz#faeb205a9d6995089bef52655ddd8245d3190627"
|
||||
integrity sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==
|
||||
|
||||
"@humanfs/core@^0.19.1":
|
||||
version "0.19.1"
|
||||
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77"
|
||||
integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==
|
||||
|
||||
"@humanfs/node@^0.16.6":
|
||||
version "0.16.7"
|
||||
resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26"
|
||||
integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==
|
||||
dependencies:
|
||||
"@humanfs/core" "^0.19.1"
|
||||
"@humanwhocodes/retry" "^0.4.0"
|
||||
|
||||
"@humanwhocodes/module-importer@^1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
|
||||
integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
|
||||
|
||||
"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2":
|
||||
version "0.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba"
|
||||
integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==
|
||||
|
||||
"@img/sharp-darwin-arm64@0.33.5":
|
||||
version "0.33.5"
|
||||
resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08"
|
||||
@@ -288,6 +379,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
|
||||
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
|
||||
|
||||
"@pkgr/core@^0.2.9":
|
||||
version "0.2.9"
|
||||
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.9.tgz#d229a7b7f9dac167a156992ef23c7f023653f53b"
|
||||
integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==
|
||||
|
||||
"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
|
||||
@@ -380,6 +476,16 @@
|
||||
resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c"
|
||||
integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==
|
||||
|
||||
"@types/estree@^1.0.6":
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
|
||||
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
|
||||
|
||||
"@types/json-schema@^7.0.15":
|
||||
version "7.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
|
||||
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||
|
||||
"@types/long@^4.0.1":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a"
|
||||
@@ -477,6 +583,16 @@ accepts@~1.3.8:
|
||||
mime-types "~2.1.34"
|
||||
negotiator "0.6.3"
|
||||
|
||||
acorn-jsx@^5.3.2:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
||||
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
|
||||
|
||||
acorn@^8.15.0:
|
||||
version "8.16.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a"
|
||||
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
|
||||
|
||||
acorn@^8.8.0:
|
||||
version "8.15.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
|
||||
@@ -499,6 +615,16 @@ agentkeepalive@^4.2.1:
|
||||
dependencies:
|
||||
humanize-ms "^1.2.1"
|
||||
|
||||
ajv@^6.12.4, ajv@^6.14.0:
|
||||
version "6.14.0"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a"
|
||||
integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==
|
||||
dependencies:
|
||||
fast-deep-equal "^3.1.1"
|
||||
fast-json-stable-stringify "^2.0.0"
|
||||
json-schema-traverse "^0.4.1"
|
||||
uri-js "^4.2.2"
|
||||
|
||||
ansi-regex@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||
@@ -509,7 +635,7 @@ ansi-regex@^6.0.1:
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1"
|
||||
integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==
|
||||
|
||||
ansi-styles@^4.0.0:
|
||||
ansi-styles@^4.0.0, ansi-styles@^4.1.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
|
||||
integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
|
||||
@@ -812,6 +938,14 @@ camelcase@6:
|
||||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
|
||||
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
|
||||
|
||||
chalk@^4.0.0:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
|
||||
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
|
||||
dependencies:
|
||||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
charenc@0.0.2:
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
|
||||
@@ -1039,7 +1173,7 @@ debug@2.6.9:
|
||||
dependencies:
|
||||
ms "2.0.0"
|
||||
|
||||
debug@4, debug@^4.1.1, debug@^4.3.4:
|
||||
debug@4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4:
|
||||
version "4.4.3"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
|
||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||
@@ -1137,6 +1271,11 @@ deep-extend@^0.6.0:
|
||||
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
|
||||
integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
|
||||
|
||||
deep-is@^0.1.3:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
|
||||
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
|
||||
|
||||
deepmerge@^4.3.1:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
|
||||
@@ -1351,6 +1490,11 @@ escape-html@~1.0.3:
|
||||
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
||||
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
|
||||
|
||||
escape-string-regexp@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
|
||||
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
|
||||
|
||||
escodegen@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17"
|
||||
@@ -1362,12 +1506,111 @@ escodegen@^2.1.0:
|
||||
optionalDependencies:
|
||||
source-map "~0.6.1"
|
||||
|
||||
eslint-config-prettier@^9.0.0:
|
||||
version "9.1.2"
|
||||
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz#90deb4fa0259592df774b600dbd1d2249a78ce91"
|
||||
integrity sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==
|
||||
|
||||
eslint-plugin-prettier@^5.0.0:
|
||||
version "5.5.5"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz#9eae11593faa108859c26f9a9c367d619a0769c0"
|
||||
integrity sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==
|
||||
dependencies:
|
||||
prettier-linter-helpers "^1.0.1"
|
||||
synckit "^0.11.12"
|
||||
|
||||
eslint-plugin-unused-imports@^4.0.0:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz#a831f0a2937d7631eba30cb87091ab7d3a5da0e1"
|
||||
integrity sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==
|
||||
|
||||
eslint-scope@^8.4.0:
|
||||
version "8.4.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82"
|
||||
integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==
|
||||
dependencies:
|
||||
esrecurse "^4.3.0"
|
||||
estraverse "^5.2.0"
|
||||
|
||||
eslint-visitor-keys@^3.4.3:
|
||||
version "3.4.3"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
|
||||
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
|
||||
|
||||
eslint-visitor-keys@^4.2.1:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
||||
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
||||
|
||||
eslint@^9.0.0:
|
||||
version "9.39.3"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.3.tgz#08d63df1533d7743c0907b32a79a7e134e63ee2f"
|
||||
integrity sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.8.0"
|
||||
"@eslint-community/regexpp" "^4.12.1"
|
||||
"@eslint/config-array" "^0.21.1"
|
||||
"@eslint/config-helpers" "^0.4.2"
|
||||
"@eslint/core" "^0.17.0"
|
||||
"@eslint/eslintrc" "^3.3.1"
|
||||
"@eslint/js" "9.39.3"
|
||||
"@eslint/plugin-kit" "^0.4.1"
|
||||
"@humanfs/node" "^0.16.6"
|
||||
"@humanwhocodes/module-importer" "^1.0.1"
|
||||
"@humanwhocodes/retry" "^0.4.2"
|
||||
"@types/estree" "^1.0.6"
|
||||
ajv "^6.12.4"
|
||||
chalk "^4.0.0"
|
||||
cross-spawn "^7.0.6"
|
||||
debug "^4.3.2"
|
||||
escape-string-regexp "^4.0.0"
|
||||
eslint-scope "^8.4.0"
|
||||
eslint-visitor-keys "^4.2.1"
|
||||
espree "^10.4.0"
|
||||
esquery "^1.5.0"
|
||||
esutils "^2.0.2"
|
||||
fast-deep-equal "^3.1.3"
|
||||
file-entry-cache "^8.0.0"
|
||||
find-up "^5.0.0"
|
||||
glob-parent "^6.0.2"
|
||||
ignore "^5.2.0"
|
||||
imurmurhash "^0.1.4"
|
||||
is-glob "^4.0.0"
|
||||
json-stable-stringify-without-jsonify "^1.0.1"
|
||||
lodash.merge "^4.6.2"
|
||||
minimatch "^3.1.2"
|
||||
natural-compare "^1.4.0"
|
||||
optionator "^0.9.3"
|
||||
|
||||
espree@^10.0.1, espree@^10.4.0:
|
||||
version "10.4.0"
|
||||
resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837"
|
||||
integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==
|
||||
dependencies:
|
||||
acorn "^8.15.0"
|
||||
acorn-jsx "^5.3.2"
|
||||
eslint-visitor-keys "^4.2.1"
|
||||
|
||||
esprima@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
|
||||
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
|
||||
|
||||
estraverse@^5.2.0:
|
||||
esquery@^1.5.0:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d"
|
||||
integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==
|
||||
dependencies:
|
||||
estraverse "^5.1.0"
|
||||
|
||||
esrecurse@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
|
||||
integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
|
||||
dependencies:
|
||||
estraverse "^5.2.0"
|
||||
|
||||
estraverse@^5.1.0, estraverse@^5.2.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
|
||||
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
|
||||
@@ -1477,11 +1720,31 @@ extract-zip@2.0.1:
|
||||
optionalDependencies:
|
||||
"@types/yauzl" "^2.9.1"
|
||||
|
||||
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||
|
||||
fast-diff@^1.1.2:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0"
|
||||
integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==
|
||||
|
||||
fast-fifo@^1.2.0, fast-fifo@^1.3.2:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c"
|
||||
integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==
|
||||
|
||||
fast-json-stable-stringify@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
|
||||
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
|
||||
|
||||
fast-levenshtein@^2.0.6:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
|
||||
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
|
||||
|
||||
fd-slicer@~1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
|
||||
@@ -1494,6 +1757,13 @@ fecha@^4.2.0:
|
||||
resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd"
|
||||
integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==
|
||||
|
||||
file-entry-cache@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f"
|
||||
integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==
|
||||
dependencies:
|
||||
flat-cache "^4.0.0"
|
||||
|
||||
file-type@^16.5.4:
|
||||
version "16.5.4"
|
||||
resolved "https://registry.yarnpkg.com/file-type/-/file-type-16.5.4.tgz#474fb4f704bee427681f98dd390058a172a6c2fd"
|
||||
@@ -1538,6 +1808,14 @@ finalhandler@~1.3.1:
|
||||
statuses "~2.0.2"
|
||||
unpipe "~1.0.0"
|
||||
|
||||
find-up@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
|
||||
integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
|
||||
dependencies:
|
||||
locate-path "^6.0.0"
|
||||
path-exists "^4.0.0"
|
||||
|
||||
fix-path@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fix-path/-/fix-path-4.0.0.tgz#bc1d14f038edb734ac46944a45454106952ca429"
|
||||
@@ -1545,6 +1823,14 @@ fix-path@^4.0.0:
|
||||
dependencies:
|
||||
shell-path "^3.0.0"
|
||||
|
||||
flat-cache@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c"
|
||||
integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==
|
||||
dependencies:
|
||||
flatted "^3.2.9"
|
||||
keyv "^4.5.4"
|
||||
|
||||
flat@^5.0.2:
|
||||
version "5.0.2"
|
||||
resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241"
|
||||
@@ -1555,6 +1841,11 @@ flatbuffers@^1.12.0:
|
||||
resolved "https://registry.yarnpkg.com/flatbuffers/-/flatbuffers-1.12.0.tgz#72e87d1726cb1b216e839ef02658aa87dcef68aa"
|
||||
integrity sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==
|
||||
|
||||
flatted@^3.2.9:
|
||||
version "3.3.4"
|
||||
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.4.tgz#0986e681008f0f13f58e18656c47967682db5ff6"
|
||||
integrity sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==
|
||||
|
||||
fn.name@1.x.x:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc"
|
||||
@@ -1687,6 +1978,13 @@ github-from-package@0.0.0:
|
||||
resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce"
|
||||
integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==
|
||||
|
||||
glob-parent@^6.0.2:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
|
||||
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
|
||||
dependencies:
|
||||
is-glob "^4.0.3"
|
||||
|
||||
glob-parent@~5.1.2:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
|
||||
@@ -1706,6 +2004,16 @@ glob@^10.3.7:
|
||||
package-json-from-dist "^1.0.0"
|
||||
path-scurry "^1.11.1"
|
||||
|
||||
globals@^14.0.0:
|
||||
version "14.0.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
|
||||
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
|
||||
|
||||
globals@^17.4.0:
|
||||
version "17.4.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-17.4.0.tgz#33d7d297ed1536b388a0e2f4bcd0ff19c8ff91b5"
|
||||
integrity sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==
|
||||
|
||||
gopd@^1.0.1, gopd@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
|
||||
@@ -1726,6 +2034,11 @@ has-flag@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
|
||||
integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==
|
||||
|
||||
has-flag@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
|
||||
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
|
||||
|
||||
has-property-descriptors@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854"
|
||||
@@ -1869,7 +2182,7 @@ ignore-by-default@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09"
|
||||
integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==
|
||||
|
||||
ignore@^5.3.0:
|
||||
ignore@^5.2.0, ignore@^5.3.0:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
|
||||
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
|
||||
@@ -1879,7 +2192,7 @@ immediate@~3.0.5:
|
||||
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
|
||||
integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==
|
||||
|
||||
import-fresh@^3.3.0:
|
||||
import-fresh@^3.2.1, import-fresh@^3.3.0:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf"
|
||||
integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==
|
||||
@@ -1887,6 +2200,11 @@ import-fresh@^3.3.0:
|
||||
parent-module "^1.0.0"
|
||||
resolve-from "^4.0.0"
|
||||
|
||||
imurmurhash@^0.1.4:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
|
||||
integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==
|
||||
|
||||
inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||
@@ -1949,7 +2267,7 @@ is-fullwidth-code-point@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
|
||||
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
|
||||
|
||||
is-glob@^4.0.1, is-glob@~4.0.1:
|
||||
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
|
||||
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
|
||||
@@ -2031,18 +2349,33 @@ js-tokens@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||
|
||||
js-yaml@^4.1.0:
|
||||
js-yaml@^4.1.0, js-yaml@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
|
||||
integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
json-buffer@3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
|
||||
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
|
||||
|
||||
json-parse-even-better-errors@^2.3.0:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
|
||||
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
|
||||
|
||||
json-schema-traverse@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
|
||||
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
|
||||
|
||||
json-stable-stringify-without-jsonify@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
|
||||
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
|
||||
|
||||
jsonpointer@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559"
|
||||
@@ -2058,6 +2391,13 @@ jszip@^3.7.1:
|
||||
readable-stream "~2.3.6"
|
||||
setimmediate "^1.0.5"
|
||||
|
||||
keyv@^4.5.4:
|
||||
version "4.5.4"
|
||||
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
|
||||
integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==
|
||||
dependencies:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
kuler@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3"
|
||||
@@ -2129,6 +2469,14 @@ leac@^0.6.0:
|
||||
resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912"
|
||||
integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==
|
||||
|
||||
levn@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
|
||||
integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
|
||||
dependencies:
|
||||
prelude-ls "^1.2.1"
|
||||
type-check "~0.4.0"
|
||||
|
||||
libbase64@1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/libbase64/-/libbase64-1.3.0.tgz#053314755a05d2e5f08bbfc48d0290e9322f4406"
|
||||
@@ -2168,6 +2516,18 @@ linkify-it@5.0.0:
|
||||
dependencies:
|
||||
uc.micro "^2.0.0"
|
||||
|
||||
locate-path@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
|
||||
integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
|
||||
dependencies:
|
||||
p-locate "^5.0.0"
|
||||
|
||||
lodash.merge@^4.6.2:
|
||||
version "4.6.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
||||
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
||||
|
||||
lodash@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||
@@ -2330,6 +2690,13 @@ minimatch@^3.1.2:
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^3.1.3:
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
|
||||
integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^9.0.4:
|
||||
version "9.0.5"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
|
||||
@@ -2423,6 +2790,11 @@ napi-build-utils@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz#13c22c0187fcfccce1461844136372a47ddc027e"
|
||||
integrity sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==
|
||||
|
||||
natural-compare@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||
|
||||
negotiator@0.6.3:
|
||||
version "0.6.3"
|
||||
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
|
||||
@@ -2643,11 +3015,37 @@ option@~0.2.1:
|
||||
resolved "https://registry.yarnpkg.com/option/-/option-0.2.4.tgz#fd475cdf98dcabb3cb397a3ba5284feb45edbfe4"
|
||||
integrity sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==
|
||||
|
||||
optionator@^0.9.3:
|
||||
version "0.9.4"
|
||||
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
|
||||
integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==
|
||||
dependencies:
|
||||
deep-is "^0.1.3"
|
||||
fast-levenshtein "^2.0.6"
|
||||
levn "^0.4.1"
|
||||
prelude-ls "^1.2.1"
|
||||
type-check "^0.4.0"
|
||||
word-wrap "^1.2.5"
|
||||
|
||||
p-finally@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
|
||||
integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==
|
||||
|
||||
p-limit@^3.0.2:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
|
||||
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
|
||||
dependencies:
|
||||
yocto-queue "^0.1.0"
|
||||
|
||||
p-locate@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
|
||||
integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
|
||||
dependencies:
|
||||
p-limit "^3.0.2"
|
||||
|
||||
p-queue@^6.6.2:
|
||||
version "6.6.2"
|
||||
resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426"
|
||||
@@ -2733,6 +3131,11 @@ parseurl@~1.3.3:
|
||||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
||||
|
||||
path-exists@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
||||
integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
|
||||
|
||||
path-is-absolute@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
|
||||
@@ -2843,6 +3246,18 @@ prebuild-install@^7.1.1:
|
||||
tar-fs "^2.0.0"
|
||||
tunnel-agent "^0.6.0"
|
||||
|
||||
prelude-ls@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
|
||||
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
||||
|
||||
prettier-linter-helpers@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz#6a31f88a4bad6c7adda253de12ba4edaea80ebcd"
|
||||
integrity sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==
|
||||
dependencies:
|
||||
fast-diff "^1.1.2"
|
||||
|
||||
prettier@^2.4.1:
|
||||
version "2.8.8"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da"
|
||||
@@ -2927,6 +3342,11 @@ punycode.js@2.3.1:
|
||||
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
|
||||
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
|
||||
|
||||
punycode@^2.1.0:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
puppeteer-core@21.5.2:
|
||||
version "21.5.2"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-21.5.2.tgz#6d3de4efb2ae65f1ee072043787b75594e88035f"
|
||||
@@ -3453,6 +3873,11 @@ strip-final-newline@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
|
||||
integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
|
||||
|
||||
strip-json-comments@^3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
|
||||
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
|
||||
|
||||
strip-json-comments@~2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
|
||||
@@ -3473,6 +3898,20 @@ supports-color@^5.5.0:
|
||||
dependencies:
|
||||
has-flag "^3.0.0"
|
||||
|
||||
supports-color@^7.1.0:
|
||||
version "7.2.0"
|
||||
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
|
||||
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
|
||||
dependencies:
|
||||
has-flag "^4.0.0"
|
||||
|
||||
synckit@^0.11.12:
|
||||
version "0.11.12"
|
||||
resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.12.tgz#abe74124264fbc00a48011b0d98bdc1cffb64a7b"
|
||||
integrity sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==
|
||||
dependencies:
|
||||
"@pkgr/core" "^0.2.9"
|
||||
|
||||
tar-fs@3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.0.4.tgz#a21dc60a2d5d9f55e0089ccd78124f1d3771dbbf"
|
||||
@@ -3643,6 +4082,13 @@ tunnel-agent@^0.6.0:
|
||||
dependencies:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
type-check@^0.4.0, type-check@~0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
|
||||
integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
|
||||
dependencies:
|
||||
prelude-ls "^1.2.1"
|
||||
|
||||
type-detect@^4.0.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c"
|
||||
@@ -3715,6 +4161,13 @@ unpipe@~1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
|
||||
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
|
||||
|
||||
uri-js@^4.2.2:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
|
||||
integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
|
||||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
url-pattern@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/url-pattern/-/url-pattern-1.0.3.tgz#0409292471b24f23c50d65a47931793d2b5acfc1"
|
||||
@@ -3829,6 +4282,11 @@ winston@^3.13.0:
|
||||
triple-beam "^1.3.0"
|
||||
winston-transport "^4.9.0"
|
||||
|
||||
word-wrap@^1.2.5:
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
|
||||
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
@@ -3920,6 +4378,11 @@ yauzl@^2.10.0, yauzl@^2.4.2:
|
||||
buffer-crc32 "~0.2.3"
|
||||
fd-slicer "~1.1.0"
|
||||
|
||||
yocto-queue@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||
|
||||
youtube-transcript-plus@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/youtube-transcript-plus/-/youtube-transcript-plus-1.1.2.tgz#f86851852a056088c11f4f6523ab0f8dba7d9711"
|
||||
|
||||
@@ -21,7 +21,7 @@ GID='1000'
|
||||
# LLM_PROVIDER='azure'
|
||||
# AZURE_OPENAI_ENDPOINT=
|
||||
# AZURE_OPENAI_KEY=
|
||||
# OPEN_MODEL_PREF='my-gpt35-deployment' # This is the "deployment" on Azure you want to use. Not the base model.
|
||||
# AZURE_OPENAI_MODEL_PREF='my-gpt35-deployment' # This is the "deployment" on Azure you want to use. Not the base model.
|
||||
# EMBEDDING_MODEL_PREF='embedder-model' # This is the "deployment" on Azure you want to use for embeddings. Not the base model. Valid base model is text-embedding-ada-002
|
||||
|
||||
# LLM_PROVIDER='anthropic'
|
||||
@@ -390,6 +390,9 @@ GID='1000'
|
||||
#------ Exa Search ----------- https://www.exa.ai/
|
||||
# AGENT_EXA_API_KEY=
|
||||
|
||||
#------ Perplexity Search ----------- [https://console.perplexity.ai](https://console.perplexity.ai)
|
||||
# AGENT_PERPLEXITY_API_KEY=
|
||||
|
||||
###########################################
|
||||
######## Other Configurations ############
|
||||
###########################################
|
||||
@@ -430,4 +433,15 @@ GID='1000'
|
||||
# Allow native tool calling for specific providers.
|
||||
# This can VASTLY improve performance and speed of agent calls.
|
||||
# Check code for supported providers who can be enabled here via this flag
|
||||
# PROVIDER_SUPPORTS_NATIVE_TOOL_CALLING="generic-openai,bedrock,localai,groq,litellm,openrouter"
|
||||
# PROVIDER_SUPPORTS_NATIVE_TOOL_CALLING="generic-openai,bedrock,localai,groq,litellm,openrouter"
|
||||
|
||||
# (optional) Maximum number of tools an agent can chain for a single response.
|
||||
# This prevents some lower-end models from infinite recursive tool calls.
|
||||
# AGENT_MAX_TOOL_CALLS=10
|
||||
|
||||
# Enable agent tool reranking to reduce token usage by selecting only the most relevant tools
|
||||
# for each query. Uses the native embedding reranker to score tools against the user's prompt.
|
||||
# Set to "true" to enable. This can reduce token costs by 80% when you have
|
||||
# many tools/MCP servers enabled.
|
||||
# AGENT_SKILL_RERANKER_ENABLED="true"
|
||||
# AGENT_SKILL_RERANKER_TOP_N=15 # (optional) Number of top tools to keep after reranking (default: 15)
|
||||
@@ -171,7 +171,7 @@ COPY --chown=anythingllm:anythingllm --from=frontend-build /app/frontend/dist /a
|
||||
# Setup the environment
|
||||
ENV NODE_ENV=production
|
||||
ENV ANYTHING_LLM_RUNTIME=docker
|
||||
ENV DEPLOYMENT_VERSION=1.11.1
|
||||
ENV DEPLOYMENT_VERSION=1.11.2
|
||||
|
||||
# Setup the healthcheck
|
||||
HEALTHCHECK --interval=1m --timeout=10s --start-period=1m \
|
||||
|
||||
@@ -51,6 +51,36 @@ function restorePlaceholders(text, placeholders) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Trans component tags like <italic>, </italic>, <bold>, </bold>, etc.
|
||||
* These are used by react-i18next Trans component for rich text formatting.
|
||||
* @param {string} text
|
||||
* @returns {{ text: string, tags: string[] }}
|
||||
*/
|
||||
function extractTransTags(text) {
|
||||
const tags = [];
|
||||
// Match opening tags <tagName> and closing tags </tagName>
|
||||
// Also matches self-closing tags <tagName />
|
||||
const modifiedText = text.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\s*\/?>/g, (match) => {
|
||||
const index = tags.length;
|
||||
tags.push(match);
|
||||
return `__TAG_${index}__`;
|
||||
});
|
||||
return { text: modifiedText, tags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore original Trans component tags from tokens.
|
||||
* @param {string} text
|
||||
* @param {string[]} tags
|
||||
* @returns {string}
|
||||
*/
|
||||
function restoreTransTags(text, tags) {
|
||||
return text.replace(/__TAG_(\d+)__/g, (_, index) => {
|
||||
return tags[parseInt(index, 10)] || `__TAG_${index}__`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that all placeholders from source exist in translated text.
|
||||
* @param {string} sourceText
|
||||
@@ -64,6 +94,19 @@ function validatePlaceholders(sourceText, translatedText) {
|
||||
return { valid: missing.length === 0, missing };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that all Trans component tags from source exist in translated text.
|
||||
* @param {string} sourceText
|
||||
* @param {string} translatedText
|
||||
* @returns {{ valid: boolean, missing: string[] }}
|
||||
*/
|
||||
function validateTransTags(sourceText, translatedText) {
|
||||
const sourceMatches = sourceText.match(/<\/?([a-zA-Z][a-zA-Z0-9]*)\s*\/?>/g) || [];
|
||||
const translatedMatches = translatedText.match(/<\/?([a-zA-Z][a-zA-Z0-9]*)\s*\/?>/g) || [];
|
||||
const missing = sourceMatches.filter(t => !translatedMatches.includes(t));
|
||||
return { valid: missing.length === 0, missing };
|
||||
}
|
||||
|
||||
class Translator {
|
||||
static modelTag = 'translategemma:4b'
|
||||
constructor() {
|
||||
@@ -87,13 +130,19 @@ class Translator {
|
||||
console.log(`\x1b[32m[Translator]\x1b[0m ${text}`, ...args);
|
||||
}
|
||||
|
||||
buildPrompt(text, sourceLangCode, targetLangCode, hasPlaceholders = false) {
|
||||
buildPrompt(text, sourceLangCode, targetLangCode, { hasPlaceholders = false, hasTags = false } = {}) {
|
||||
const sourceLanguage = this.getLanguageName(sourceLangCode);
|
||||
const targetLanguage = this.getLanguageName(targetLangCode);
|
||||
const placeholderInstruction = hasPlaceholders
|
||||
? `\nIMPORTANT: The text contains placeholders like __PLACEHOLDER_0__, __PLACEHOLDER_1__, etc. You MUST keep these placeholders exactly as they are in the translation - do not translate, modify, or remove them.`
|
||||
: '';
|
||||
return `You are a professional ${sourceLanguage} (${sourceLangCode.toLowerCase()}) to ${targetLanguage} (${targetLangCode.toLowerCase()}) translator. Your goal is to accurately convey the meaning and nuances of the original ${sourceLanguage} text while adhering to ${targetLanguage} grammar, vocabulary, and cultural sensitivities.${placeholderInstruction}
|
||||
|
||||
let specialInstructions = '';
|
||||
if (hasPlaceholders || hasTags) {
|
||||
const items = [];
|
||||
if (hasPlaceholders) items.push('__PLACEHOLDER_0__, __PLACEHOLDER_1__');
|
||||
if (hasTags) items.push('__TAG_0__, __TAG_1__');
|
||||
specialInstructions = `\nIMPORTANT: The text contains tokens like ${items.join(', ')}, etc. You MUST keep these tokens exactly as they are in the translation - do not translate, modify, or remove them.`;
|
||||
}
|
||||
|
||||
return `You are a professional ${sourceLanguage} (${sourceLangCode.toLowerCase()}) to ${targetLanguage} (${targetLangCode.toLowerCase()}) translator. Your goal is to accurately convey the meaning and nuances of the original ${sourceLanguage} text while adhering to ${targetLanguage} grammar, vocabulary, and cultural sensitivities.${specialInstructions}
|
||||
Produce only the ${targetLanguage} translation, without any additional explanations or commentary. Please translate the following ${sourceLanguage} text into ${targetLanguage}:
|
||||
|
||||
|
||||
@@ -113,11 +162,15 @@ ${text}`
|
||||
|
||||
async translate(text, sourceLangCode, targetLangCode) {
|
||||
// Extract placeholders like {{variableName}} and replace with tokens
|
||||
const { text: textWithTokens, placeholders } = extractPlaceholders(text);
|
||||
const { text: textWithPlaceholders, placeholders } = extractPlaceholders(text);
|
||||
const hasPlaceholders = placeholders.length > 0;
|
||||
|
||||
const prompt = this.buildPrompt(textWithTokens, sourceLangCode, targetLangCode, hasPlaceholders);
|
||||
const response = await fetch(`http://localhost:11434/api/chat`, {
|
||||
// Extract Trans component tags like <italic>, </italic>, etc.
|
||||
const { text: textWithTokens, tags } = extractTransTags(textWithPlaceholders);
|
||||
const hasTags = tags.length > 0;
|
||||
|
||||
const prompt = this.buildPrompt(textWithTokens, sourceLangCode, targetLangCode, { hasPlaceholders, hasTags });
|
||||
const response = await fetch(`http://127.0.0.1:11434/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -132,6 +185,22 @@ ${text}`
|
||||
const data = await response.json();
|
||||
let translatedText = this.cleanOutputText(data.message.content);
|
||||
|
||||
// Restore Trans component tags first (order matters since tags may contain placeholders)
|
||||
if (hasTags) {
|
||||
translatedText = restoreTransTags(translatedText, tags);
|
||||
|
||||
// Validate all tags were preserved
|
||||
const tagValidation = validateTransTags(text, translatedText);
|
||||
if (!tagValidation.valid) {
|
||||
console.warn(`Warning: Missing Trans tags in translation: ${tagValidation.missing.join(', ')}`);
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
if (!translatedText.includes(tags[i])) {
|
||||
console.warn(` Tag ${tags[i]} was lost in translation`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore original placeholders
|
||||
if (hasPlaceholders) {
|
||||
translatedText = restorePlaceholders(translatedText, placeholders);
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import renderMarkdown from "@/utils/chat/markdown";
|
||||
import DOMPurify from "@/utils/chat/purify";
|
||||
|
||||
export default function EditingChatBubble({
|
||||
message,
|
||||
index,
|
||||
type,
|
||||
handleMessageChange,
|
||||
removeMessage,
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [tempMessage, setTempMessage] = useState(message[type]);
|
||||
const isUser = type === "user";
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p
|
||||
className={`text-xs text-white light:text-black/80 ${isUser ? "text-right" : ""}`}
|
||||
>
|
||||
{isUser
|
||||
? t("common.user")
|
||||
: t("customization.items.welcome-messages.assistant")}
|
||||
</p>
|
||||
<div
|
||||
className={`relative flex w-full mt-2 items-start ${
|
||||
isUser ? "justify-end" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className={`transition-all duration-300 absolute z-10 text-white rounded-full hover:bg-neutral-700 light:hover:invert hover:border-white border-transparent border shadow-lg ${
|
||||
isUser ? "right-0 mr-2" : "ml-2"
|
||||
}`}
|
||||
style={{ top: "6px", [isUser ? "right" : "left"]: "290px" }}
|
||||
onClick={() => removeMessage(index)}
|
||||
>
|
||||
<X className="m-0.5" size={20} />
|
||||
</button>
|
||||
<div
|
||||
className={`p-2 max-w-full md:w-[290px] text-black rounded-[8px] ${
|
||||
isUser ? "bg-[#41444C] text-white" : "bg-[#2E3036] text-white"
|
||||
}
|
||||
}`}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
value={tempMessage}
|
||||
onChange={(e) => setTempMessage(e.target.value)}
|
||||
onBlur={() => {
|
||||
handleMessageChange(index, type, tempMessage);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
autoFocus
|
||||
className={`w-full light:text-white ${
|
||||
isUser ? "bg-[#41444C] text-white" : "bg-[#2E3036] text-white"
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
tempMessage && (
|
||||
<div
|
||||
className="markdown font-[500] md:font-semibold text-sm md:text-base break-words light:invert"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(renderMarkdown(tempMessage)),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import System from "@/models/system";
|
||||
import { LMSTUDIO_COMMON_URLS } from "@/utils/constants";
|
||||
import { CaretDown, CaretUp, Info, CircleNotch } from "@phosphor-icons/react";
|
||||
import {
|
||||
CaretDown,
|
||||
CaretUp,
|
||||
Info,
|
||||
CircleNotch,
|
||||
Warning,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import useProviderEndpointAutoDiscovery from "@/hooks/useProviderEndpointAutoDiscovery";
|
||||
|
||||
@@ -224,24 +230,51 @@ function LMStudioModelSelection({ settings, basePath = null, apiKey = null }) {
|
||||
if (loading || customModels.length == 0) {
|
||||
return (
|
||||
<div className="flex flex-col w-60">
|
||||
<label className="text-white text-sm font-semibold block mb-2">
|
||||
LM Studio Embedding Model
|
||||
</label>
|
||||
<div className="flex items-center mb-2 gap-x-1">
|
||||
<label className="text-white text-sm font-semibold">
|
||||
Embedding Model
|
||||
</label>
|
||||
{!loading && !!basePath && (
|
||||
<>
|
||||
<Warning
|
||||
size={18}
|
||||
className="text-red-400 cursor-pointer"
|
||||
data-tooltip-id="lmstudio-embedding-model"
|
||||
/>
|
||||
<Tooltip
|
||||
id="lmstudio-embedding-model"
|
||||
place="top"
|
||||
delayShow={300}
|
||||
delayHide={400}
|
||||
clickable={true}
|
||||
className="tooltip !text-xs !opacity-100"
|
||||
style={{
|
||||
maxWidth: "250px",
|
||||
whiteSpace: "normal",
|
||||
wordWrap: "break-word",
|
||||
}}
|
||||
>
|
||||
<p className="text-xs leading-[18px] font-base">
|
||||
Could not reach LM Studio. Verify the URL is correct and the
|
||||
LMStudio server is running and accessible.
|
||||
</p>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
name="EmbeddingModelPref"
|
||||
disabled={true}
|
||||
className="border-none bg-theme-settings-input-bg border-gray-500 text-white text-sm rounded-lg block w-full p-2.5"
|
||||
>
|
||||
<option disabled={true} selected={true}>
|
||||
{!!basePath
|
||||
{loading
|
||||
? "--loading available models--"
|
||||
: "Enter LM Studio URL first"}
|
||||
: !!basePath
|
||||
? "No models found"
|
||||
: "Enter LM Studio URL first"}
|
||||
</option>
|
||||
</select>
|
||||
<p className="text-xs leading-[18px] font-base text-white text-opacity-60 mt-2">
|
||||
Select the LM Studio model for embeddings. Models will load after
|
||||
entering a valid LM Studio URL.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,31 @@ export default function LemonadeEmbeddingOptions({ settings }) {
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-60">
|
||||
<div
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-id="lemonade-embedding-api-key"
|
||||
className="flex gap-x-1 items-center mb-3"
|
||||
>
|
||||
<label className="text-white text-sm font-semibold block">
|
||||
API Key (optional)
|
||||
</label>
|
||||
<Info
|
||||
size={16}
|
||||
className="text-theme-text-secondary cursor-pointer"
|
||||
/>
|
||||
<Tooltip id="lemonade-embedding-api-key">
|
||||
The API key for your Lemonade instance
|
||||
</Tooltip>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="LemonadeLLMApiKey"
|
||||
defaultValue={settings?.LemonadeLLMApiKey ? "*".repeat(20) : ""}
|
||||
className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-start mt-4">
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Info, CaretDown, CaretUp, CircleNotch } from "@phosphor-icons/react";
|
||||
import {
|
||||
Info,
|
||||
CaretDown,
|
||||
CaretUp,
|
||||
CircleNotch,
|
||||
Warning,
|
||||
} from "@phosphor-icons/react";
|
||||
import paths from "@/utils/paths";
|
||||
import System from "@/models/system";
|
||||
import { LMSTUDIO_COMMON_URLS } from "@/utils/constants";
|
||||
@@ -249,18 +255,49 @@ function LMStudioModelSelection({ settings, basePath = null, apiKey = null }) {
|
||||
if (loading || customModels.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col w-60">
|
||||
<label className="text-white text-sm font-semibold block mb-2">
|
||||
LM Studio Model
|
||||
</label>
|
||||
<div className="flex items-center mb-2 gap-x-1">
|
||||
<label className="text-white text-sm font-semibold">
|
||||
Selected Model
|
||||
</label>
|
||||
{!loading && !!basePath && (
|
||||
<>
|
||||
<Warning
|
||||
size={18}
|
||||
className="text-red-400 cursor-pointer"
|
||||
data-tooltip-id="lmstudio-selected-model"
|
||||
/>
|
||||
<Tooltip
|
||||
id="lmstudio-selected-model"
|
||||
place="top"
|
||||
delayShow={300}
|
||||
delayHide={400}
|
||||
clickable={true}
|
||||
className="tooltip !text-xs !opacity-100 z-99"
|
||||
style={{
|
||||
maxWidth: "250px",
|
||||
whiteSpace: "normal",
|
||||
wordWrap: "break-word",
|
||||
}}
|
||||
>
|
||||
<p className="text-xs leading-[18px] font-base">
|
||||
Could not reach LM Studio. Verify the URL is correct and the
|
||||
LMStudio server is running and accessible.
|
||||
</p>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
name="LMStudioModelPref"
|
||||
disabled={true}
|
||||
className="border-none bg-theme-settings-input-bg border-gray-500 text-white text-sm rounded-lg block w-full p-2.5"
|
||||
>
|
||||
<option disabled={true} selected={true}>
|
||||
{!!basePath
|
||||
{loading
|
||||
? "--loading available models--"
|
||||
: "Enter LM Studio URL first"}
|
||||
: !!basePath
|
||||
? "No models found"
|
||||
: "Enter LM Studio URL first"}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -270,7 +307,7 @@ function LMStudioModelSelection({ settings, basePath = null, apiKey = null }) {
|
||||
return (
|
||||
<div className="flex flex-col w-60">
|
||||
<label className="text-white text-sm font-semibold block mb-2">
|
||||
LM Studio Model
|
||||
Selected Model
|
||||
</label>
|
||||
<select
|
||||
name="LMStudioModelPref"
|
||||
|
||||
@@ -159,6 +159,43 @@ export default function LemonadeOptions({ settings }) {
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-60">
|
||||
<div className="flex items-center gap-1 mb-3">
|
||||
<label className="text-white text-sm font-semibold block">
|
||||
API Key (optional)
|
||||
</label>
|
||||
<Tooltip
|
||||
id="lemonade-api-key"
|
||||
place="top"
|
||||
delayShow={300}
|
||||
delayHide={800}
|
||||
clickable={true}
|
||||
className="tooltip !text-xs !opacity-100 z-99"
|
||||
style={{
|
||||
maxWidth: "350px",
|
||||
whiteSpace: "normal",
|
||||
wordWrap: "break-word",
|
||||
}}
|
||||
>
|
||||
The API key for your Lemonade server
|
||||
</Tooltip>
|
||||
<div
|
||||
className="text-theme-text-secondary cursor-pointer hover:bg-theme-bg-primary flex items-center justify-center rounded-full"
|
||||
data-tooltip-id="lemonade-api-key"
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-delay-hide={800}
|
||||
>
|
||||
<Info size={18} className="text-theme-text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="LemonadeLLMApiKey"
|
||||
defaultValue={settings?.LemonadeLLMApiKey ? "*".repeat(20) : ""}
|
||||
className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<LemonadeModelSelection
|
||||
selectedModelId={selectedModelId}
|
||||
setSelectedModelId={setSelectedModelId}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function FileRow({ item, selected, toggleSelection }) {
|
||||
return (
|
||||
<tr
|
||||
onClick={() => toggleSelection(item)}
|
||||
className={`text-theme-text-primary text-xs grid grid-cols-12 py-2 pl-3.5 pr-8 hover:bg-theme-file-picker-hover cursor-pointer file-row ${
|
||||
className={`text-theme-text-primary text-xs grid grid-cols-12 py-2 pl-8 pr-8 hover:bg-theme-file-picker-hover cursor-pointer file-row ${
|
||||
selected ? "selected light:text-white" : ""
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { middleTruncate } from "@/utils/directories";
|
||||
|
||||
export default function FolderRow({
|
||||
item,
|
||||
totalItems = 0,
|
||||
selected,
|
||||
onRowClick,
|
||||
toggleSelection,
|
||||
@@ -60,6 +61,11 @@ export default function FolderRow({
|
||||
<p className="whitespace-nowrap overflow-show max-w-[400px]">
|
||||
{middleTruncate(item.name, 35)}
|
||||
</p>
|
||||
{totalItems > 0 && (
|
||||
<span className="text-theme-text-secondary text-[10px] font-medium ml-1.5 shrink-0">
|
||||
({totalItems})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="col-span-2 pl-3.5" />
|
||||
<p className="col-span-2 pl-2" />
|
||||
|
||||
@@ -193,6 +193,11 @@ function Directory({
|
||||
setContextMenu({ visible: false, x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const totalDocCount = (files?.items ?? []).reduce((acc, folder) => {
|
||||
if (folder.type === "folder") return folder.items.length + acc;
|
||||
return acc;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-8 pb-8" onContextMenu={handleContextMenu}>
|
||||
@@ -232,6 +237,13 @@ function Directory({
|
||||
<div className="relative w-[560px] h-[310px] bg-theme-settings-input-bg rounded-2xl overflow-hidden border border-theme-modal-border">
|
||||
<div className="absolute top-0 left-0 right-0 z-10 rounded-t-2xl text-theme-text-primary text-xs grid grid-cols-12 py-2 px-8 border-b border-white/20 light:border-theme-modal-border bg-theme-settings-input-bg">
|
||||
<p className="col-span-6">Name</p>
|
||||
{totalDocCount > 0 && (
|
||||
<p className="col-span-6 text-right text-theme-text-secondary">
|
||||
{t(`connectors.directory.total-documents`, {
|
||||
count: totalDocCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto h-full pt-8">
|
||||
@@ -249,6 +261,7 @@ function Directory({
|
||||
<FolderRow
|
||||
key={index}
|
||||
item={item}
|
||||
totalItems={item.items?.length ?? 0}
|
||||
selected={isSelected(
|
||||
item.id,
|
||||
item.type === "folder" ? item : null
|
||||
@@ -310,7 +323,6 @@ function Directory({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UploadFile
|
||||
workspace={workspace}
|
||||
fetchKeys={fetchKeys}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CloudArrowUp } from "@phosphor-icons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import showToast from "../../../../../utils/toast";
|
||||
import System from "../../../../../models/system";
|
||||
@@ -34,7 +34,7 @@ export default function UploadFile({
|
||||
if (!response.ok) {
|
||||
showToast(`Error uploading link: ${data.error}`, "error");
|
||||
} else {
|
||||
fetchKeys(true);
|
||||
await fetchKeys(true, { autoSelectNew: true });
|
||||
showToast("Link uploaded successfully", "success");
|
||||
formEl.reset();
|
||||
}
|
||||
@@ -42,11 +42,12 @@ export default function UploadFile({
|
||||
setFetchingUrl(false);
|
||||
};
|
||||
|
||||
// Queue all fetchKeys calls through the same debouncer to prevent spamming the server.
|
||||
// either a success or error will trigger a fetchKeys call so the UI is not stuck loading.
|
||||
const debouncedFetchKeys = debounce(() => fetchKeys(true), 1000);
|
||||
const handleUploadSuccess = () => debouncedFetchKeys();
|
||||
const handleUploadError = () => debouncedFetchKeys();
|
||||
const debouncedFetchKeysRef = useRef(
|
||||
debounce((fn, opts) => fn(true, opts), 1000)
|
||||
);
|
||||
const handleUploadSuccess = () =>
|
||||
debouncedFetchKeysRef.current(fetchKeys, { autoSelectNew: true });
|
||||
const handleUploadError = () => debouncedFetchKeysRef.current(fetchKeys, {});
|
||||
|
||||
const onDrop = async (acceptedFiles, rejections) => {
|
||||
const newAccepted = acceptedFiles.map((file) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import PreLoader from "@/components/Preloader";
|
||||
import { dollarFormat } from "@/utils/numbers";
|
||||
import WorkspaceFileRow from "./WorkspaceFileRow";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import ModalWrapper from "@/components/ModalWrapper";
|
||||
@@ -23,11 +22,14 @@ function WorkspaceDirectory({
|
||||
fetchKeys,
|
||||
hasChanges,
|
||||
saveChanges,
|
||||
embeddingCosts,
|
||||
movedItems,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedItems, setSelectedItems] = useState({});
|
||||
const embeddedDocCount = (files?.items ?? []).reduce(
|
||||
(sum, folder) => sum + (folder.items?.length ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
const toggleSelection = (item) => {
|
||||
setSelectedItems((prevSelectedItems) => {
|
||||
@@ -101,7 +103,6 @@ function WorkspaceDirectory({
|
||||
<div className="shrink-0 w-3 h-3" />
|
||||
<p className="ml-[7px] text-theme-text-primary">Name</p>
|
||||
</div>
|
||||
<p className="col-span-2" />
|
||||
</div>
|
||||
<div className="w-full h-[calc(100%-40px)] flex items-center justify-center flex-col gap-y-5">
|
||||
<PreLoader />
|
||||
@@ -157,7 +158,13 @@ function WorkspaceDirectory({
|
||||
)}
|
||||
<p className="ml-[7px] text-theme-text-primary">Name</p>
|
||||
</div>
|
||||
<p className="col-span-2" />
|
||||
{embeddedDocCount > 0 && (
|
||||
<p className="col-span-2 text-right text-theme-text-secondary pr-2">
|
||||
{t(`connectors.directory.total-documents`, {
|
||||
count: embeddedDocCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-y-auto h-[calc(100%-40px)]">
|
||||
{files.items.some((folder) => folder.items.length > 0) ||
|
||||
@@ -223,22 +230,7 @@ function WorkspaceDirectory({
|
||||
</div>
|
||||
</div>
|
||||
{hasChanges && (
|
||||
<div className="flex items-center justify-between py-6">
|
||||
<div className="text-white/80">
|
||||
<p className="text-sm font-semibold">
|
||||
{embeddingCosts === 0
|
||||
? ""
|
||||
: `Estimated Cost: ${
|
||||
embeddingCosts < 0.01
|
||||
? `< $0.01`
|
||||
: dollarFormat(embeddingCosts)
|
||||
}`}
|
||||
</p>
|
||||
<p className="mt-2 text-xs italic" hidden={embeddingCosts === 0}>
|
||||
{t("connectors.directory.costs")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end py-6">
|
||||
<button
|
||||
onClick={(e) => handleSaveChanges(e)}
|
||||
className="border border-slate-200 px-5 py-2.5 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { ArrowsDownUp } from "@phosphor-icons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Workspace from "../../../../models/workspace";
|
||||
import System from "../../../../models/system";
|
||||
import showToast from "../../../../utils/toast";
|
||||
import Directory from "./Directory";
|
||||
import WorkspaceDirectory from "./WorkspaceDirectory";
|
||||
|
||||
// OpenAI Cost per token
|
||||
// ref: https://openai.com/pricing#:~:text=%C2%A0/%201K%20tokens-,Embedding%20models,-Build%20advanced%20search
|
||||
|
||||
const MODEL_COSTS = {
|
||||
"text-embedding-ada-002": 0.0000001, // $0.0001 / 1K tokens
|
||||
"text-embedding-3-small": 0.00000002, // $0.00002 / 1K tokens
|
||||
"text-embedding-3-large": 0.00000013, // $0.00013 / 1K tokens
|
||||
};
|
||||
|
||||
export default function DocumentSettings({ workspace, systemSettings }) {
|
||||
export default function DocumentSettings({ workspace }) {
|
||||
const [highlightWorkspace, setHighlightWorkspace] = useState(false);
|
||||
const [availableDocs, setAvailableDocs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -23,10 +14,24 @@ export default function DocumentSettings({ workspace, systemSettings }) {
|
||||
const [selectedItems, setSelectedItems] = useState({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [movedItems, setMovedItems] = useState([]);
|
||||
const [embeddingsCost, setEmbeddingsCost] = useState(0);
|
||||
const [loadingMessage, setLoadingMessage] = useState("");
|
||||
const availableDocsRef = useRef([]);
|
||||
|
||||
useEffect(() => {
|
||||
availableDocsRef.current = availableDocs;
|
||||
}, [availableDocs]);
|
||||
|
||||
async function fetchKeys(refetchWorkspace = false, options = {}) {
|
||||
const { autoSelectNew = false } = options;
|
||||
const previousIds = new Set();
|
||||
if (autoSelectNew && availableDocsRef.current?.items) {
|
||||
for (const folder of availableDocsRef.current.items) {
|
||||
for (const file of folder.items ?? []) {
|
||||
if (file?.id) previousIds.add(file.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchKeys(refetchWorkspace = false) {
|
||||
setLoading(true);
|
||||
const localFiles = await System.localFiles();
|
||||
const currentWorkspace = refetchWorkspace
|
||||
@@ -37,7 +42,7 @@ export default function DocumentSettings({ workspace, systemSettings }) {
|
||||
currentWorkspace.documents.map((doc) => doc.docpath) || [];
|
||||
|
||||
// Documents that are not in the workspace
|
||||
const availableDocs = {
|
||||
const filteredAvailableDocs = {
|
||||
...localFiles,
|
||||
items: localFiles.items.map((folder) => {
|
||||
if (folder.items && folder.type === "folder") {
|
||||
@@ -56,7 +61,7 @@ export default function DocumentSettings({ workspace, systemSettings }) {
|
||||
};
|
||||
|
||||
// Documents that are already in the workspace
|
||||
const workspaceDocs = {
|
||||
const filteredWorkspaceDocs = {
|
||||
...localFiles,
|
||||
items: localFiles.items.map((folder) => {
|
||||
if (folder.items && folder.type === "folder") {
|
||||
@@ -74,8 +79,23 @@ export default function DocumentSettings({ workspace, systemSettings }) {
|
||||
}),
|
||||
};
|
||||
|
||||
setAvailableDocs(availableDocs);
|
||||
setWorkspaceDocs(workspaceDocs);
|
||||
setAvailableDocs(filteredAvailableDocs);
|
||||
setWorkspaceDocs(filteredWorkspaceDocs);
|
||||
|
||||
if (autoSelectNew) {
|
||||
const newSelected = {};
|
||||
for (const folder of filteredAvailableDocs.items ?? []) {
|
||||
for (const file of folder.items ?? []) {
|
||||
if (file?.id && !previousIds.has(file.id)) {
|
||||
newSelected[file.id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(newSelected).length > 0) {
|
||||
setSelectedItems((prev) => ({ ...prev, ...newSelected }));
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -134,25 +154,6 @@ export default function DocumentSettings({ workspace, systemSettings }) {
|
||||
}
|
||||
}
|
||||
|
||||
let totalTokenCount = 0;
|
||||
newMovedItems.forEach((item) => {
|
||||
const { cached, token_count_estimate } = item;
|
||||
if (!cached) {
|
||||
totalTokenCount += token_count_estimate;
|
||||
}
|
||||
});
|
||||
|
||||
// Do not do cost estimation unless the embedding engine is OpenAi.
|
||||
if (systemSettings?.EmbeddingEngine === "openai") {
|
||||
const COST_PER_TOKEN =
|
||||
MODEL_COSTS[
|
||||
systemSettings?.EmbeddingModelPref || "text-embedding-ada-002"
|
||||
];
|
||||
|
||||
const dollarAmount = (totalTokenCount / 1000) * COST_PER_TOKEN;
|
||||
setEmbeddingsCost(dollarAmount);
|
||||
}
|
||||
|
||||
setMovedItems([...movedItems, ...newMovedItems]);
|
||||
|
||||
let newAvailableDocs = JSON.parse(JSON.stringify(availableDocs));
|
||||
@@ -222,7 +223,6 @@ export default function DocumentSettings({ workspace, systemSettings }) {
|
||||
fetchKeys={fetchKeys}
|
||||
hasChanges={hasChanges}
|
||||
saveChanges={updateWorkspace}
|
||||
embeddingCosts={embeddingsCost}
|
||||
movedItems={movedItems}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -102,7 +102,7 @@ const ManageWorkspace = ({ hideModal = noop, providedSlug = null }) => {
|
||||
)}
|
||||
|
||||
{selectedTab === "documents" ? (
|
||||
<DocumentSettings workspace={workspace} systemSettings={settings} />
|
||||
<DocumentSettings workspace={workspace} />
|
||||
) : (
|
||||
<DataConnectors workspace={workspace} systemSettings={settings} />
|
||||
)}
|
||||
|
||||
@@ -4,15 +4,16 @@ import useLogo from "@/hooks/useLogo";
|
||||
import {
|
||||
House,
|
||||
List,
|
||||
Robot,
|
||||
Flask,
|
||||
Gear,
|
||||
UserCircleGear,
|
||||
PencilSimpleLine,
|
||||
Nut,
|
||||
Toolbox,
|
||||
Globe,
|
||||
Plugs,
|
||||
} from "@phosphor-icons/react";
|
||||
import AgentIcon from "@/media/animations/agent-static.png";
|
||||
import CommunityHubIcon from "@/media/illustrations/community-hub.png";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import Footer from "../Footer";
|
||||
@@ -295,30 +296,43 @@ const SidebarOptions = ({ user = null, t }) => (
|
||||
/>
|
||||
<Option
|
||||
btnText={t("settings.agent-skills")}
|
||||
icon={<Robot className="h-5 w-5 flex-shrink-0" />}
|
||||
icon={
|
||||
<img
|
||||
src={AgentIcon}
|
||||
alt="Agent"
|
||||
className="h-5 w-5 flex-shrink-0 light:invert"
|
||||
/>
|
||||
}
|
||||
href={paths.settings.agentSkills()}
|
||||
user={user}
|
||||
flex={true}
|
||||
roles={["admin"]}
|
||||
/>
|
||||
<Option
|
||||
btnText="Community Hub"
|
||||
icon={<Globe className="h-5 w-5 flex-shrink-0" />}
|
||||
btnText={t("settings.community-hub.title")}
|
||||
icon={
|
||||
<img
|
||||
src={CommunityHubIcon}
|
||||
alt="Community Hub"
|
||||
className="h-5 w-5 flex-shrink-0 light:invert"
|
||||
/>
|
||||
}
|
||||
user={user}
|
||||
childOptions={[
|
||||
{
|
||||
btnText: "Explore Trending",
|
||||
btnText: t("settings.community-hub.trending"),
|
||||
href: paths.communityHub.trending(),
|
||||
flex: true,
|
||||
roles: ["admin"],
|
||||
},
|
||||
{
|
||||
btnText: "Your Account",
|
||||
btnText: t("settings.community-hub.your-account"),
|
||||
href: paths.communityHub.authentication(),
|
||||
flex: true,
|
||||
roles: ["admin"],
|
||||
},
|
||||
{
|
||||
btnText: "Import Item",
|
||||
btnText: t("settings.community-hub.import-item"),
|
||||
href: paths.communityHub.importItem(),
|
||||
flex: true,
|
||||
roles: ["admin"],
|
||||
@@ -350,6 +364,19 @@ const SidebarOptions = ({ user = null, t }) => (
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Option
|
||||
btnText={t("settings.channels")}
|
||||
icon={<Plugs className="h-5 w-5 flex-shrink-0" />}
|
||||
user={user}
|
||||
childOptions={[
|
||||
{
|
||||
btnText: t("settings.available-channels.telegram"),
|
||||
href: paths.settings.telegram(),
|
||||
flex: true,
|
||||
hidden: !!user,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Option
|
||||
btnText={t("settings.tools")}
|
||||
icon={<Toolbox className="h-5 w-5 flex-shrink-0" />}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SidebarSimple } from "@phosphor-icons/react";
|
||||
import paths from "@/utils/paths";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
const SIDEBAR_TOGGLE_STORAGE_KEY = "anythingllm_sidebar_toggle";
|
||||
export const SIDEBAR_TOGGLE_EVENT = "sidebar-toggle";
|
||||
|
||||
/**
|
||||
* Returns the previous state of the sidebar from localStorage.
|
||||
@@ -62,6 +63,11 @@ export function useSidebarToggle() {
|
||||
SIDEBAR_TOGGLE_STORAGE_KEY,
|
||||
showSidebar ? "open" : "closed"
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_TOGGLE_EVENT, {
|
||||
detail: { open: showSidebar },
|
||||
})
|
||||
);
|
||||
}, [showSidebar]);
|
||||
|
||||
return { showSidebar, setShowSidebar, canToggleSidebar };
|
||||
|
||||
@@ -31,7 +31,6 @@ import CustomCell from "./CustomCell.jsx";
|
||||
import Tooltip from "./CustomTooltip.jsx";
|
||||
import { safeJsonParse } from "@/utils/request.js";
|
||||
import renderMarkdown from "@/utils/chat/markdown.js";
|
||||
import { WorkspaceProfileImage } from "../PromptReply/index.jsx";
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { saveAs } from "file-saver";
|
||||
import { useGenerateImage } from "recharts-to-png";
|
||||
@@ -41,7 +40,7 @@ const dataFormatter = (number) => {
|
||||
return Intl.NumberFormat("us").format(number).toString();
|
||||
};
|
||||
|
||||
export function Chartable({ props, workspace }) {
|
||||
export function Chartable({ props }) {
|
||||
const [getDivJpeg, { ref }] = useGenerateImage({
|
||||
quality: 1,
|
||||
type: "image/jpeg",
|
||||
@@ -387,20 +386,17 @@ export function Chartable({ props, workspace }) {
|
||||
|
||||
if (!!props.chatId) {
|
||||
return (
|
||||
<div className="flex justify-center items-end w-full">
|
||||
<div className="py-2 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col">
|
||||
<div className="flex gap-x-5">
|
||||
<WorkspaceProfileImage workspace={workspace} />
|
||||
<div className="relative w-full">
|
||||
<DownloadGraph onClick={handleDownload} />
|
||||
<div ref={ref}>{renderChart()}</div>
|
||||
<span
|
||||
className={`flex flex-col gap-y-1 mt-2`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMarkdown(content.caption),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-start w-full">
|
||||
<div className="py-2 px-4 w-full flex flex-col md:max-w-[80%]">
|
||||
<div className="relative w-full">
|
||||
<DownloadGraph onClick={handleDownload} />
|
||||
<div ref={ref}>{renderChart()}</div>
|
||||
<span
|
||||
className="flex flex-col gap-y-1 mt-2"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMarkdown(content.caption),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -408,20 +404,18 @@ export function Chartable({ props, workspace }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-end w-full">
|
||||
<div className="py-2 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col">
|
||||
<div className="flex justify-start w-full">
|
||||
<div className="py-2 px-4 w-full flex flex-col md:max-w-[80%]">
|
||||
<div className="relative w-full">
|
||||
<DownloadGraph onClick={handleDownload} />
|
||||
<div ref={ref}>{renderChart()}</div>
|
||||
</div>
|
||||
<div className="flex gap-x-5">
|
||||
<span
|
||||
className={`flex flex-col gap-y-1 mt-2`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMarkdown(content.caption),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="flex flex-col gap-y-1 mt-2"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMarkdown(content.caption),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { Fragment, useState, useEffect } from "react";
|
||||
import { decode as HTMLDecode } from "he";
|
||||
import truncate from "truncate";
|
||||
import ModalWrapper from "@/components/ModalWrapper";
|
||||
import { middleTruncate } from "@/utils/directories";
|
||||
import {
|
||||
CaretRight,
|
||||
FileText,
|
||||
Info,
|
||||
ArrowSquareOut,
|
||||
@@ -14,16 +12,73 @@ import {
|
||||
LinkSimple,
|
||||
GitlabLogo,
|
||||
} from "@phosphor-icons/react";
|
||||
import ConfluenceLogo from "@/media/dataConnectors/confluence.png";
|
||||
import DrupalWikiLogo from "@/media/dataConnectors/drupalwiki.png";
|
||||
import ObsidianLogo from "@/media/dataConnectors/obsidian.png";
|
||||
import PaperlessNgxLogo from "@/media/dataConnectors/paperlessngx.png";
|
||||
import { toPercentString } from "@/utils/numbers";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import pluralize from "pluralize";
|
||||
import useTextSize from "@/hooks/useTextSize";
|
||||
import { useSourcesSidebar } from "../../SourcesSidebar";
|
||||
|
||||
function combineLikeSources(sources) {
|
||||
const CIRCLE_ICONS = {
|
||||
file: FileText,
|
||||
link: LinkSimple,
|
||||
youtube: YoutubeLogo,
|
||||
github: GithubLogo,
|
||||
gitlab: GitlabLogo,
|
||||
confluence: LinkSimple,
|
||||
drupalwiki: FileText,
|
||||
obsidian: FileText,
|
||||
paperlessNgx: FileText,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a circle with a source type icon inside, or a favicon if URL is provided.
|
||||
* @param {"file"|"link"|"youtube"|"github"|"gitlab"|"confluence"|"drupalwiki"|"obsidian"|"paperlessNgx"} props.type
|
||||
* @param {number} [props.size] - Circle diameter in px
|
||||
* @param {number} [props.iconSize] - Icon size in px
|
||||
* @param {string} [props.url] - Optional URL to fetch favicon from
|
||||
*/
|
||||
export function SourceTypeCircle({
|
||||
type = "file",
|
||||
size = 22,
|
||||
iconSize = 12,
|
||||
url = null,
|
||||
}) {
|
||||
const Icon = CIRCLE_ICONS[type] || CIRCLE_ICONS.file;
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
let faviconUrl = null;
|
||||
if (type === "link" && url) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
faviconUrl = `https://www.google.com/s2/favicons?domain=${hostname}&sz=64`;
|
||||
} catch {
|
||||
faviconUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setImgError(false);
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white light:bg-slate-100 rounded-full flex items-center justify-center overflow-hidden"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{faviconUrl && !imgError ? (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt="favicon"
|
||||
style={{ width: size, height: size }}
|
||||
className="object-cover"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Icon size={iconSize} weight="bold" className="text-black" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function combineLikeSources(sources) {
|
||||
const combined = {};
|
||||
sources.forEach((source) => {
|
||||
const { id, title, text, chunkSource = "", score = null } = source;
|
||||
@@ -42,106 +97,88 @@ function combineLikeSources(sources) {
|
||||
}
|
||||
|
||||
export default function Citations({ sources = [] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedSource, setSelectedSource] = useState(null);
|
||||
const {
|
||||
sidebarOpen,
|
||||
openSidebar,
|
||||
closeSidebar,
|
||||
sources: currentSources,
|
||||
} = useSourcesSidebar();
|
||||
const { t } = useTranslation();
|
||||
const { textSizeClass } = useTextSize();
|
||||
|
||||
if (sources.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mt-4 justify-left">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`border-none font-semibold text-white/50 light:text-black/50 font-medium italic ${textSizeClass} text-left ml-14 pt-2 ${
|
||||
open ? "pb-2" : ""
|
||||
} hover:text-white/75 hover:light:text-black/75 transition-all duration-300`}
|
||||
>
|
||||
{open
|
||||
? t("chat_window.hide_citations")
|
||||
: t("chat_window.show_citations")}
|
||||
<CaretRight
|
||||
weight="bold"
|
||||
size={14}
|
||||
className={`inline-block ml-1 transform transition-transform duration-300 ${
|
||||
open ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="flex flex-wrap flex-col items-start overflow-x-scroll no-scroll mt-1 ml-14 gap-y-2">
|
||||
{combineLikeSources(sources).map((source, idx) => (
|
||||
<Citation
|
||||
key={source.title || idx.toString()}
|
||||
source={source}
|
||||
onClick={() => setSelectedSource(source)}
|
||||
textSizeClass={textSizeClass}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedSource && (
|
||||
<CitationDetailModal
|
||||
source={selectedSource}
|
||||
onClose={() => setSelectedSource(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const combined = combineLikeSources(sources);
|
||||
const visibleSources = combined.slice(0, 3);
|
||||
const remainingCount = Math.max(0, combined.length - 3);
|
||||
|
||||
const Citation = ({ source, onClick, textSizeClass }) => {
|
||||
const { title, references = 1 } = source;
|
||||
if (!title) return null;
|
||||
const chunkSourceInfo = parseChunkSource(source);
|
||||
const truncatedTitle = chunkSourceInfo?.text ?? middleTruncate(title, 25);
|
||||
const CitationIcon = ICONS.hasOwnProperty(chunkSourceInfo?.icon)
|
||||
? ICONS[chunkSourceInfo.icon]
|
||||
: ICONS.file;
|
||||
function handleOpenSourcesSidebar() {
|
||||
if (sidebarOpen && sources === currentSources) {
|
||||
closeSidebar();
|
||||
} else {
|
||||
openSidebar(sources);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`flex doc__source gap-x-1 ${textSizeClass}`}
|
||||
onClick={onClick}
|
||||
onClick={handleOpenSourcesSidebar}
|
||||
className="w-fit flex items-center gap-[5px] px-[10px] py-[4px] rounded-full hover:bg-white/5 light:hover:bg-black/5 transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start flex-1 pt-[4px]">
|
||||
<CitationIcon size={16} />
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-y-[0.2px] px-1">
|
||||
<p
|
||||
className={`!m-0 font-semibold whitespace-nowrap text-theme-text-primary hover:opacity-55 ${textSizeClass}`}
|
||||
>
|
||||
{truncatedTitle}
|
||||
</p>
|
||||
<p
|
||||
className={`!m-0 text-[10px] font-medium text-theme-text-secondary ${textSizeClass}`}
|
||||
>{`${references} ${pluralize("Reference", Number(references) || 1)}`}</p>
|
||||
<span className="text-xs text-white light:text-slate-800">
|
||||
{t("chat_window.sources")}
|
||||
</span>
|
||||
<div
|
||||
className="relative h-[22px]"
|
||||
style={{ width: `${visibleSources.length * 17 + 5}px` }}
|
||||
>
|
||||
{visibleSources.map((source, idx) => {
|
||||
const info = parseChunkSource(source);
|
||||
return (
|
||||
<div
|
||||
key={source.title || idx}
|
||||
className="absolute top-0 size-[22px] rounded-full border-2 border-zinc-800 light:border-white"
|
||||
style={{ left: `${idx * 17}px`, zIndex: 3 - idx }}
|
||||
>
|
||||
<SourceTypeCircle
|
||||
type={info.icon}
|
||||
size={18}
|
||||
iconSize={10}
|
||||
url={info.href}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{remainingCount > 0 && (
|
||||
<span className="text-xs text-white light:text-slate-800">
|
||||
+ {remainingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function omitChunkHeader(text) {
|
||||
export function omitChunkHeader(text) {
|
||||
if (!text.includes("<document_metadata>")) return text;
|
||||
return text.split("</document_metadata>")[1].trim();
|
||||
}
|
||||
|
||||
function CitationDetailModal({ source, onClose }) {
|
||||
export function CitationDetailModal({ source, onClose }) {
|
||||
const { references, title, chunks } = source;
|
||||
const { isUrl, text: webpageUrl, href: linkTo } = parseChunkSource(source);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalWrapper isOpen={!!source}>
|
||||
<div className="w-full max-w-2xl bg-theme-bg-secondary rounded-lg shadow border-2 border-theme-modal-border overflow-hidden">
|
||||
<div className="relative p-6 border-b rounded-t border-theme-modal-border">
|
||||
<div className="w-full max-w-2xl bg-zinc-900 light:bg-white rounded-lg shadow border-2 border-zinc-700 light:border-slate-300 overflow-hidden">
|
||||
<div className="relative p-6 border-b rounded-t border-zinc-700 light:border-slate-300">
|
||||
<div className="w-full flex gap-x-2 items-center">
|
||||
{isUrl ? (
|
||||
<a
|
||||
href={linkTo}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xl w-[90%] font-semibold text-white whitespace-nowrap hover:underline hover:text-blue-300 flex items-center gap-x-1"
|
||||
className="text-xl w-[90%] font-semibold text-white light:text-slate-900 whitespace-nowrap hover:underline hover:text-blue-300 light:hover:text-blue-600 flex items-center gap-x-1"
|
||||
>
|
||||
<div className="flex items-center gap-x-1 max-w-full overflow-hidden">
|
||||
<h3 className="truncate text-ellipsis whitespace-nowrap overflow-hidden w-full">
|
||||
@@ -151,22 +188,26 @@ function CitationDetailModal({ source, onClose }) {
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<h3 className="text-xl font-semibold text-white overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||
<h3 className="text-xl font-semibold text-white light:text-slate-900 overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||
{truncate(title, 45)}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
{references > 1 && (
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
<p className="text-xs text-zinc-400 light:text-slate-500 mt-2">
|
||||
Referenced {references} times.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
className="absolute top-4 right-4 transition-all duration-300 bg-transparent rounded-lg text-sm p-1 inline-flex items-center hover:bg-theme-modal-border hover:border-theme-modal-border hover:border-opacity-50 border-transparent border"
|
||||
className="absolute top-4 right-4 transition-all duration-300 bg-transparent rounded-lg text-sm p-1 inline-flex items-center hover:bg-zinc-700 light:hover:bg-slate-200 border-transparent border"
|
||||
>
|
||||
<X size={24} weight="bold" className="text-white" />
|
||||
<X
|
||||
size={24}
|
||||
weight="bold"
|
||||
className="text-white light:text-slate-900"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -176,28 +217,31 @@ function CitationDetailModal({ source, onClose }) {
|
||||
<div className="py-7 px-9 space-y-2 flex-col">
|
||||
{chunks.map(({ text, score }, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<div className="pt-6 text-white">
|
||||
<div className="pt-6 text-white light:text-slate-900">
|
||||
<div className="flex flex-col w-full justify-start pb-6 gap-y-1">
|
||||
<p className="text-white whitespace-pre-line">
|
||||
<p className="text-white light:text-slate-900 whitespace-pre-line">
|
||||
{HTMLDecode(omitChunkHeader(text))}
|
||||
</p>
|
||||
|
||||
{!!score && (
|
||||
<div className="w-full flex items-center text-xs text-white/60 gap-x-2 cursor-default">
|
||||
<div className="w-full flex items-center text-xs text-white/60 light:text-slate-500 gap-x-2 cursor-default">
|
||||
<div
|
||||
data-tooltip-id="similarity-score"
|
||||
data-tooltip-content={`This is the semantic similarity score of this chunk of text compared to your query calculated by the vector database.`}
|
||||
className="flex items-center gap-x-1"
|
||||
>
|
||||
<Info size={14} />
|
||||
<p>{toPercentString(score)} match</p>
|
||||
<p>
|
||||
{toPercentString(score)}{" "}
|
||||
{t("chat_window.similarity_match")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{idx !== chunks.length - 1 && (
|
||||
<hr className="border-theme-modal-border" />
|
||||
<hr className="border-zinc-700 light:border-slate-300" />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
@@ -228,7 +272,7 @@ const supportedSources = [
|
||||
* @param {{title: string, chunks: {text: string, chunkSource: string}[]}} options
|
||||
* @returns {{isUrl: boolean, text: string, href: string, icon: string}}
|
||||
*/
|
||||
function parseChunkSource({ title = "", chunks = [] }) {
|
||||
export function parseChunkSource({ title = "", chunks = [] }) {
|
||||
const nullResponse = {
|
||||
isUrl: false,
|
||||
text: null,
|
||||
@@ -315,33 +359,3 @@ function parseChunkSource({ title = "", chunks = [] }) {
|
||||
}
|
||||
return nullResponse;
|
||||
}
|
||||
|
||||
const ConfluenceIcon = ({ size = 16, ...props }) => (
|
||||
<img src={ConfluenceLogo} {...props} width={size} height={size} />
|
||||
);
|
||||
const DrupalWikiIcon = ({ size = 16, ...props }) => (
|
||||
<img src={DrupalWikiLogo} {...props} width={size} height={size} />
|
||||
);
|
||||
const ObsidianIcon = ({ size = 16, ...props }) => (
|
||||
<img src={ObsidianLogo} {...props} width={size} height={size} />
|
||||
);
|
||||
const PaperlessNgxIcon = ({ size = 16, ...props }) => (
|
||||
<img
|
||||
src={PaperlessNgxLogo}
|
||||
{...props}
|
||||
width={size}
|
||||
height={size}
|
||||
className="rounded-sm bg-white"
|
||||
/>
|
||||
);
|
||||
const ICONS = {
|
||||
file: FileText,
|
||||
link: LinkSimple,
|
||||
youtube: YoutubeLogo,
|
||||
github: GithubLogo,
|
||||
gitlab: GitlabLogo,
|
||||
confluence: ConfluenceIcon,
|
||||
drupalwiki: DrupalWikiIcon,
|
||||
obsidian: ObsidianIcon,
|
||||
paperlessNgx: PaperlessNgxIcon,
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ function ActionMenu({ chatId, forkThread, isEditing, role }) {
|
||||
<div className="mt-2 -ml-0.5 relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="border-none text-[var(--theme-sidebar-footer-icon-fill)] hover:text-[var(--theme-sidebar-footer-icon-fill)] transition-colors duration-200"
|
||||
className="border-none text-zinc-300 light:text-slate-500 transition-colors duration-200"
|
||||
data-tooltip-id="action-menu"
|
||||
data-tooltip-content={t("chat_window.more_actions")}
|
||||
aria-label={t("chat_window.more_actions")}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Pencil } from "@phosphor-icons/react";
|
||||
import { Info, Pencil } from "@phosphor-icons/react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Appearance from "@/models/appearance";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -53,14 +53,10 @@ export function EditMessageAction({ chatId = null, role, isEditing }) {
|
||||
? t("chat_window.edit_prompt")
|
||||
: t("chat_window.edit_response")
|
||||
} `}
|
||||
className="border-none text-zinc-300"
|
||||
className="border-none text-zinc-300 light:text-slate-500"
|
||||
aria-label={`Edit ${role === "user" ? t("chat_window.edit_prompt") : t("chat_window.edit_response")}`}
|
||||
>
|
||||
<Pencil
|
||||
color="var(--theme-sidebar-footer-icon-fill)"
|
||||
size={21}
|
||||
className="mb-1"
|
||||
/>
|
||||
<Pencil size={21} className="mb-1" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -75,17 +71,30 @@ export function EditMessageForm({
|
||||
saveChanges,
|
||||
}) {
|
||||
const formRef = useRef(null);
|
||||
const { t } = useTranslation();
|
||||
function handleSaveMessage(e) {
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.target);
|
||||
const editedMessage = form.get("editedMessage");
|
||||
const editedMessage = formRef.current.value;
|
||||
saveChanges({ editedMessage, chatId, role, attachments });
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(EDIT_EVENT, { detail: { chatId, role, attachments } })
|
||||
);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const editedMessage = formRef.current.value;
|
||||
saveChanges({
|
||||
editedMessage,
|
||||
chatId,
|
||||
role,
|
||||
attachments,
|
||||
saveOnly: true,
|
||||
});
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(EDIT_EVENT, { detail: { chatId, role, attachments } })
|
||||
);
|
||||
}
|
||||
|
||||
function cancelEdits() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(EDIT_EVENT, { detail: { chatId, role, attachments } })
|
||||
@@ -94,36 +103,91 @@ export function EditMessageForm({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!formRef || !formRef.current) return;
|
||||
if (!formRef?.current) return;
|
||||
formRef.current.focus();
|
||||
adjustTextArea({ target: formRef.current });
|
||||
}, [formRef]);
|
||||
}, []);
|
||||
|
||||
if (role === "user") {
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col w-full max-w-[650px]"
|
||||
>
|
||||
<textarea
|
||||
ref={formRef}
|
||||
name="editedMessage"
|
||||
spellCheck={Appearance.get("enableSpellCheck")}
|
||||
className="text-white light:text-slate-900 w-full rounded-2xl bg-zinc-800 light:bg-slate-100 border border-sky-300 focus:border-sky-300 active:outline-none focus:outline-none focus:ring-0 px-4 py-3 resize-none overflow-hidden"
|
||||
defaultValue={message}
|
||||
onChange={adjustTextArea}
|
||||
/>
|
||||
<EditActionBar
|
||||
onCancel={cancelEdits}
|
||||
onSave={handleSave}
|
||||
isUserMessage
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSaveMessage} className="flex flex-col w-full">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col w-full max-w-[650px]"
|
||||
>
|
||||
<textarea
|
||||
ref={formRef}
|
||||
name="editedMessage"
|
||||
spellCheck={Appearance.get("enableSpellCheck")}
|
||||
className="text-white w-full rounded bg-theme-bg-secondary border border-white/20 active:outline-none focus:outline-none focus:ring-0 pr-16 pl-1.5 pt-1.5 resize-y"
|
||||
className="text-white light:text-slate-900 w-full rounded-2xl bg-zinc-800 light:bg-slate-100 border border-sky-300 focus:border-sky-300 active:outline-none focus:outline-none focus:ring-0 px-4 py-3 resize-none overflow-hidden"
|
||||
defaultValue={message}
|
||||
onChange={adjustTextArea}
|
||||
/>
|
||||
<div className="mt-3 flex justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
className="border-none px-2 py-1 bg-gray-200 text-gray-700 font-medium rounded-md mr-2 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
{t("chat_window.save_submit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="border-none px-2 py-1 bg-historical-msg-system text-white font-medium rounded-md hover:bg-historical-msg-user/90 light:hover:text-white focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
|
||||
onClick={cancelEdits}
|
||||
>
|
||||
{t("chat_window.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
<EditActionBar onCancel={cancelEdits} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EditActionBar({ onCancel, onSave, isUserMessage = false }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mt-2 flex flex-col md:flex-row md:items-center justify-between gap-2 bg-zinc-800 light:bg-slate-200 rounded-lg p-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info
|
||||
size={12}
|
||||
className="shrink-0 mt-0.5 text-zinc-200 light:text-slate-800"
|
||||
/>
|
||||
<span className="text-zinc-200 light:text-slate-800 text-xs leading-4">
|
||||
{isUserMessage
|
||||
? t("chat_window.edit_info_user")
|
||||
: t("chat_window.edit_info_assistant")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="border-none text-white light:text-slate-900 text-sm font-medium w-[70px] h-9 rounded-lg hover:bg-white/5 light:hover:bg-slate-300"
|
||||
>
|
||||
{t("chat_window.cancel")}
|
||||
</button>
|
||||
{isUserMessage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="border border-zinc-600 light:border-slate-600 text-white light:text-slate-900 text-sm font-medium w-[70px] h-9 rounded-lg hover:bg-white/5 light:hover:bg-slate-300"
|
||||
>
|
||||
{t("chat_window.save")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="border-none bg-zinc-50 light:bg-slate-800 text-zinc-800 light:text-white text-sm font-medium w-[70px] h-9 rounded-lg hover:bg-zinc-200 light:hover:bg-slate-800"
|
||||
>
|
||||
{isUserMessage ? t("chat_window.submit") : t("chat_window.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { formatDateTimeAsMoment } from "@/utils/directories";
|
||||
import { numberWithCommas } from "@/utils/numbers";
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
import { isMobile } from "react-device-detect";
|
||||
const MetricsContext = React.createContext();
|
||||
const SHOW_METRICS_KEY = "anythingllm_show_chat_metrics";
|
||||
const SHOW_METRICS_EVENT = "anythingllm_show_metrics_change";
|
||||
@@ -116,7 +117,7 @@ export default function RenderMetrics({ metrics = {} }) {
|
||||
// Inherit the showMetricsAutomatically state from the MetricsProvider so the state is shared across all chats
|
||||
const { showMetricsAutomatically, setShowMetricsAutomatically } =
|
||||
useContext(MetricsContext);
|
||||
if (!metrics?.duration || !metrics?.outputTps) return null;
|
||||
if (!metrics?.duration || !metrics?.outputTps || isMobile) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -128,9 +129,9 @@ export default function RenderMetrics({ metrics = {} }) {
|
||||
? "Click to only show metrics when hovering"
|
||||
: "Click to show metrics as soon as they are available"
|
||||
}
|
||||
className={`border-none flex justify-end items-center gap-x-[8px] ${showMetricsAutomatically ? "opacity-100" : "opacity-0"} md:group-hover:opacity-100 transition-all duration-300`}
|
||||
className={`border-none flex md:justify-end items-center gap-x-[8px] -ml-7 ${showMetricsAutomatically ? "opacity-100" : "opacity-0"} md:group-hover:opacity-100 transition-all duration-300`}
|
||||
>
|
||||
<p className="cursor-pointer text-xs font-mono text-theme-text-secondary opacity-50">
|
||||
<p className="cursor-pointer text-xs font-mono text-zinc-400 light:text-slate-500">
|
||||
{buildMetricsString(metrics)}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function AsyncTTSMessage({ slug, chatId }) {
|
||||
? t("pause_tts_speech_message")
|
||||
: t("chat_window.tts_speak_message")
|
||||
}
|
||||
className="border-none text-[var(--theme-sidebar-footer-icon-fill)]"
|
||||
className="border-none text-zinc-300 light:text-slate-500"
|
||||
aria-label={speaking ? "Pause speech" : "Speak message"}
|
||||
>
|
||||
{speaking ? (
|
||||
|
||||
@@ -3,6 +3,10 @@ import NativeTTSMessage from "./native";
|
||||
import AsyncTTSMessage from "./asyncTts";
|
||||
import PiperTTSMessage from "./piperTTS";
|
||||
|
||||
function WrapTTS({ children }) {
|
||||
return <div className="mx-2">{children}</div>;
|
||||
}
|
||||
|
||||
export default function TTSMessage({ slug, chatId, message }) {
|
||||
const { settings, provider, loading } = useTTSProvider();
|
||||
if (!chatId || loading) return null;
|
||||
@@ -11,16 +15,26 @@ export default function TTSMessage({ slug, chatId, message }) {
|
||||
case "openai":
|
||||
case "generic-openai":
|
||||
case "elevenlabs":
|
||||
return <AsyncTTSMessage chatId={chatId} slug={slug} />;
|
||||
return (
|
||||
<WrapTTS>
|
||||
<AsyncTTSMessage chatId={chatId} slug={slug} />
|
||||
</WrapTTS>
|
||||
);
|
||||
case "piper_local":
|
||||
return (
|
||||
<PiperTTSMessage
|
||||
chatId={chatId}
|
||||
voiceId={settings?.TTSPiperTTSVoiceModel}
|
||||
message={message}
|
||||
/>
|
||||
<WrapTTS>
|
||||
<PiperTTSMessage
|
||||
chatId={chatId}
|
||||
voiceId={settings?.TTSPiperTTSVoiceModel}
|
||||
message={message}
|
||||
/>
|
||||
</WrapTTS>
|
||||
);
|
||||
default:
|
||||
return <NativeTTSMessage chatId={chatId} message={message} />;
|
||||
return (
|
||||
<WrapTTS>
|
||||
<NativeTTSMessage chatId={chatId} message={message} />
|
||||
</WrapTTS>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function NativeTTSMessage({ chatId, message }) {
|
||||
data-tooltip-content={
|
||||
speaking ? "Pause TTS speech of message" : "TTS Speak message"
|
||||
}
|
||||
className="border-none text-[var(--theme-sidebar-footer-icon-fill)]"
|
||||
className="border-none text-zinc-300 light:text-slate-500"
|
||||
aria-label={speaking ? "Pause speech" : "Speak message"}
|
||||
>
|
||||
{speaking ? (
|
||||
|
||||
@@ -18,7 +18,6 @@ const Actions = ({
|
||||
isEditing,
|
||||
role,
|
||||
metrics = {},
|
||||
alignmentCls = "",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedFeedback, setSelectedFeedback] = useState(feedbackScore);
|
||||
@@ -30,15 +29,21 @@ const Actions = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex w-full justify-between items-center ${alignmentCls}`}>
|
||||
<div
|
||||
className={`flex w-full flex-wrap items-center gap-y-1 ${role === "user" ? "justify-end" : "justify-between"}`}
|
||||
>
|
||||
<div className="flex justify-start items-center gap-x-[8px]">
|
||||
<CopyMessage message={message} />
|
||||
<div className="md:group-hover:opacity-100 transition-all duration-300 md:opacity-0 flex justify-start items-center gap-x-[8px]">
|
||||
<EditMessageAction
|
||||
chatId={chatId}
|
||||
role={role}
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
<div
|
||||
className={`flex justify-start items-center gap-x-[8px] ${role === "user" ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<CopyMessage message={message} />
|
||||
<EditMessageAction
|
||||
chatId={chatId}
|
||||
role={role}
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
</div>
|
||||
{isLastMessage && !isEditing && (
|
||||
<RegenerateMessage
|
||||
regenerateMessage={regenerateMessage}
|
||||
@@ -80,11 +85,10 @@ function FeedbackButton({
|
||||
onClick={handleFeedback}
|
||||
data-tooltip-id="feedback-button"
|
||||
data-tooltip-content={tooltipContent}
|
||||
className="text-zinc-300"
|
||||
className="text-zinc-300 light:text-slate-500"
|
||||
aria-label={tooltipContent}
|
||||
>
|
||||
<IconComponent
|
||||
color="var(--theme-sidebar-footer-icon-fill)"
|
||||
size={20}
|
||||
className="mb-1"
|
||||
weight={isSelected ? "fill" : "regular"}
|
||||
@@ -105,21 +109,13 @@ function CopyMessage({ message }) {
|
||||
onClick={() => copyText(message)}
|
||||
data-tooltip-id="copy-assistant-text"
|
||||
data-tooltip-content={t("chat_window.copy")}
|
||||
className="text-zinc-300"
|
||||
className="text-zinc-300 light:text-slate-500"
|
||||
aria-label={t("chat_window.copy")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check
|
||||
color="var(--theme-sidebar-footer-icon-fill)"
|
||||
size={20}
|
||||
className="mb-1"
|
||||
/>
|
||||
<Check size={20} className="mb-1" />
|
||||
) : (
|
||||
<Copy
|
||||
color="var(--theme-sidebar-footer-icon-fill)"
|
||||
size={20}
|
||||
className="mb-1"
|
||||
/>
|
||||
<Copy size={20} className="mb-1" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -136,15 +132,10 @@ function RegenerateMessage({ regenerateMessage, chatId }) {
|
||||
onClick={() => regenerateMessage(chatId)}
|
||||
data-tooltip-id="regenerate-assistant-text"
|
||||
data-tooltip-content={t("chat_window.regenerate_response")}
|
||||
className="border-none text-zinc-300"
|
||||
className="border-none text-zinc-300 light:text-slate-500"
|
||||
aria-label={t("chat_window.regenerate")}
|
||||
>
|
||||
<ArrowsClockwise
|
||||
color="var(--theme-sidebar-footer-icon-fill)"
|
||||
size={20}
|
||||
className="mb-1"
|
||||
weight="fill"
|
||||
/>
|
||||
<ArrowsClockwise size={20} className="mb-1" weight="fill" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React, { memo } from "react";
|
||||
import React, { memo, useEffect, useRef, useState } from "react";
|
||||
import { Info, Warning } from "@phosphor-icons/react";
|
||||
import UserIcon from "../../../../UserIcon";
|
||||
import Actions from "./Actions";
|
||||
import renderMarkdown from "@/utils/chat/markdown";
|
||||
import { userFromStorage } from "@/utils/request";
|
||||
import Citations from "../Citation";
|
||||
import { v4 } from "uuid";
|
||||
import DOMPurify from "@/utils/chat/purify";
|
||||
@@ -36,7 +34,6 @@ const HistoricalMessage = ({
|
||||
saveEditedMessage,
|
||||
forkThread,
|
||||
metrics = {},
|
||||
alignmentCls = "",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { isEditing } = useEditMessage({ chatId, role });
|
||||
@@ -53,91 +50,120 @@ const HistoricalMessage = ({
|
||||
const isRefusalMessage =
|
||||
role === "assistant" && message === chatQueryRefusalResponse(workspace);
|
||||
|
||||
if (completeDelete) return null;
|
||||
|
||||
if (!!error) {
|
||||
return (
|
||||
<div
|
||||
key={uuid}
|
||||
className={`flex justify-center items-end w-full bg-theme-bg-chat`}
|
||||
>
|
||||
<div className="py-8 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col">
|
||||
<div className={`flex gap-x-5 ${alignmentCls}`}>
|
||||
<ProfileImage role={role} workspace={workspace} />
|
||||
<div className="p-2 rounded-lg bg-red-50 text-red-500">
|
||||
<span className="inline-block">
|
||||
<Warning className="h-4 w-4 mb-1 inline-block" /> Could not
|
||||
respond to message.
|
||||
</span>
|
||||
<p className="text-xs font-mono mt-2 border-l-2 border-red-300 pl-2 bg-red-200 p-2 rounded-sm">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<div key={uuid} className="flex justify-start w-full">
|
||||
<div className="py-4 pl-0 pr-4 flex flex-col md:max-w-[80%]">
|
||||
<div className="p-2 rounded-lg bg-red-50 text-red-500">
|
||||
<span className="inline-block">
|
||||
<Warning className="h-4 w-4 mb-1 inline-block" /> Could not
|
||||
respond to message.
|
||||
</span>
|
||||
<p className="text-xs font-mono mt-2 border-l-2 border-red-300 pl-2 bg-red-200 p-2 rounded-sm">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (completeDelete) return null;
|
||||
if (role === "user") {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div key={uuid} className="flex justify-end w-full py-4 px-4">
|
||||
<EditMessageForm
|
||||
role={role}
|
||||
chatId={chatId}
|
||||
message={message}
|
||||
attachments={attachments}
|
||||
adjustTextArea={adjustTextArea}
|
||||
saveChanges={saveEditedMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={uuid}
|
||||
onAnimationEnd={onEndAnimation}
|
||||
className={`${
|
||||
isDeleted ? "animate-remove" : ""
|
||||
} flex justify-center items-end w-full group bg-theme-bg-chat`}
|
||||
>
|
||||
<div className="py-8 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col">
|
||||
<div className={`flex gap-x-5 ${alignmentCls}`}>
|
||||
<div className="flex flex-col items-center">
|
||||
<ProfileImage role={role} workspace={workspace} />
|
||||
<div className="mt-1 -mb-10">
|
||||
{role === "assistant" && (
|
||||
<TTSMessage
|
||||
slug={workspace?.slug}
|
||||
chatId={chatId}
|
||||
message={message}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<EditMessageForm
|
||||
role={role}
|
||||
chatId={chatId}
|
||||
message={message}
|
||||
attachments={attachments}
|
||||
adjustTextArea={adjustTextArea}
|
||||
saveChanges={saveEditedMessage}
|
||||
/>
|
||||
) : (
|
||||
<div className="break-words">
|
||||
return (
|
||||
<div
|
||||
key={uuid}
|
||||
onAnimationEnd={onEndAnimation}
|
||||
className={`${isDeleted ? "animate-remove" : ""} flex justify-end w-full group`}
|
||||
>
|
||||
<div className="py-4 px-4 flex flex-col items-end">
|
||||
<div className="bg-zinc-800 light:bg-slate-100 rounded-[20px] rounded-br-none px-4 py-3.5 max-w-[600px] [&_p]:m-0">
|
||||
<TruncatableContent>
|
||||
<RenderChatContent
|
||||
role={role}
|
||||
message={message}
|
||||
messageId={uuid}
|
||||
/>
|
||||
{isRefusalMessage && (
|
||||
<Link
|
||||
data-tooltip-id="query-refusal-info"
|
||||
data-tooltip-content={`${t("chat.refusal.tooltip-description")}`}
|
||||
className="!no-underline group !flex w-fit"
|
||||
to={paths.chatModes()}
|
||||
target="_blank"
|
||||
>
|
||||
<div className="flex flex-row items-center gap-x-1 group-hover:opacity-100 opacity-60 w-fit">
|
||||
<Info className="text-theme-text-secondary" />
|
||||
<p className="!m-0 !p-0 text-theme-text-secondary !no-underline text-xs cursor-pointer">
|
||||
{t("chat.refusal.tooltip-title")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
<ChatAttachments attachments={attachments} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-x-5 ml-14">
|
||||
</TruncatableContent>
|
||||
</div>
|
||||
<Actions
|
||||
message={message}
|
||||
feedbackScore={feedbackScore}
|
||||
chatId={chatId}
|
||||
slug={workspace?.slug}
|
||||
isLastMessage={isLastMessage}
|
||||
regenerateMessage={regenerateMessage}
|
||||
isEditing={isEditing}
|
||||
role={role}
|
||||
forkThread={forkThread}
|
||||
metrics={metrics}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={uuid}
|
||||
onAnimationEnd={onEndAnimation}
|
||||
className={`${isDeleted ? "animate-remove" : ""} flex justify-start w-full group`}
|
||||
>
|
||||
<div className="py-4 px-4 md:pl-0 flex flex-col w-full">
|
||||
{isEditing ? (
|
||||
<EditMessageForm
|
||||
role={role}
|
||||
chatId={chatId}
|
||||
message={message}
|
||||
attachments={attachments}
|
||||
adjustTextArea={adjustTextArea}
|
||||
saveChanges={saveEditedMessage}
|
||||
/>
|
||||
) : (
|
||||
<div className="break-words">
|
||||
<RenderChatContent role={role} message={message} messageId={uuid} />
|
||||
{isRefusalMessage && (
|
||||
<Link
|
||||
data-tooltip-id="query-refusal-info"
|
||||
data-tooltip-content={`${t("chat.refusal.tooltip-description")}`}
|
||||
className="!no-underline group !flex w-fit"
|
||||
to={paths.chatModes()}
|
||||
target="_blank"
|
||||
>
|
||||
<div className="flex flex-row items-center gap-x-1 group-hover:opacity-100 opacity-60 w-fit">
|
||||
<Info className="text-theme-text-secondary" />
|
||||
<p className="!m-0 !p-0 text-theme-text-secondary !no-underline text-xs cursor-pointer">
|
||||
{t("chat.refusal.tooltip-title")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
<ChatAttachments attachments={attachments} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start md:items-center gap-x-1">
|
||||
<TTSMessage
|
||||
slug={workspace?.slug}
|
||||
chatId={chatId}
|
||||
message={message}
|
||||
/>
|
||||
<Actions
|
||||
message={message}
|
||||
feedbackScore={feedbackScore}
|
||||
@@ -149,7 +175,6 @@ const HistoricalMessage = ({
|
||||
role={role}
|
||||
forkThread={forkThread}
|
||||
metrics={metrics}
|
||||
alignmentCls={alignmentCls}
|
||||
/>
|
||||
</div>
|
||||
{role === "assistant" && <Citations sources={sources} />}
|
||||
@@ -158,40 +183,21 @@ const HistoricalMessage = ({
|
||||
);
|
||||
};
|
||||
|
||||
function ProfileImage({ role, workspace }) {
|
||||
if (role === "assistant" && workspace.pfpUrl) {
|
||||
return (
|
||||
<div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden">
|
||||
<img
|
||||
src={workspace.pfpUrl}
|
||||
alt="Workspace profile picture"
|
||||
className="absolute top-0 left-0 w-full h-full object-cover rounded-full bg-white"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UserIcon
|
||||
user={{
|
||||
uid: role === "user" ? userFromStorage()?.username : workspace.slug,
|
||||
}}
|
||||
role={role}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(
|
||||
HistoricalMessage,
|
||||
// Skip re-render the historical message:
|
||||
// if the content is the exact same AND (not streaming)
|
||||
// the lastMessage status is the same (regen icon)
|
||||
// and the chatID matches between renders. (feedback icons)
|
||||
// - if the content is the exact same
|
||||
// - AND (not streaming)
|
||||
// - the lastMessage status is the same (regen icon)
|
||||
// - the chatID matches between renders. (feedback icons)
|
||||
// - the metrics are the same (metrics are updated in real time)
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.message === nextProps.message &&
|
||||
prevProps.isLastMessage === nextProps.isLastMessage &&
|
||||
prevProps.chatId === nextProps.chatId
|
||||
prevProps.chatId === nextProps.chatId &&
|
||||
JSON.stringify(prevProps.metrics) === JSON.stringify(nextProps.metrics) &&
|
||||
JSON.stringify(prevProps.sources) === JSON.stringify(nextProps.sources)
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -199,18 +205,73 @@ export default memo(
|
||||
function ChatAttachments({ attachments = [] }) {
|
||||
if (!attachments.length) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-4 mt-4">
|
||||
{attachments.map((item) => (
|
||||
<img
|
||||
alt={`Attachment: ${item.name}`}
|
||||
key={item.name}
|
||||
src={item.contentString}
|
||||
className="max-w-[300px] rounded-md"
|
||||
className="w-[120px] h-[120px] object-cover rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TruncatableContent({ children }) {
|
||||
const contentRef = useRef(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
setIsOverflowing(contentRef.current.scrollHeight > 250);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showTruncation = !isExpanded && isOverflowing;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={showTruncation ? "max-h-[250px] overflow-hidden" : ""}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{showTruncation && (
|
||||
<>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-[36px] light:hidden pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(39, 39, 42, 0.00) 0%, rgba(39, 39, 42, 0.65) 50%, #27272A 100%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-[36px] hidden light:block pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(241, 245, 249, 0.00) 0%, rgba(241, 245, 249, 0.65) 50%, #F1F5F9 100%)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isOverflowing && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-zinc-300 light:text-slate-700 hover:text-white light:hover:text-slate-900 text-xs font-medium leading-4 mt-2"
|
||||
>
|
||||
{isExpanded ? t("chat_window.see_less") : t("chat_window.see_more")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const RenderChatContent = memo(
|
||||
({ role, message, messageId }) => {
|
||||
// If the message is not from the assistant, we can render it directly
|
||||
@@ -218,7 +279,7 @@ const RenderChatContent = memo(
|
||||
if (role !== "assistant")
|
||||
return (
|
||||
<span
|
||||
className="flex flex-col gap-y-1"
|
||||
className="flex flex-col gap-y-1 text-white light:text-slate-900"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(renderMarkdown(message)),
|
||||
}}
|
||||
@@ -252,7 +313,7 @@ const RenderChatContent = memo(
|
||||
<ThoughtChainComponent content={thoughtChain} messageId={messageId} />
|
||||
)}
|
||||
<span
|
||||
className="flex flex-col gap-y-1"
|
||||
className="flex flex-col gap-y-1 text-white light:text-slate-900"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(renderMarkdown(msgToRender)),
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable react-hooks/refs */
|
||||
import { memo, useRef, useEffect } from "react";
|
||||
import { Warning } from "@phosphor-icons/react";
|
||||
import UserIcon from "../../../../UserIcon";
|
||||
import renderMarkdown from "@/utils/chat/markdown";
|
||||
import DOMPurify from "@/utils/chat/purify";
|
||||
import Citations from "../Citation";
|
||||
import {
|
||||
THOUGHT_REGEX_CLOSE,
|
||||
@@ -11,28 +11,14 @@ import {
|
||||
ThoughtChainComponent,
|
||||
} from "../ThoughtContainer";
|
||||
|
||||
const PromptReply = ({
|
||||
uuid,
|
||||
reply,
|
||||
pending,
|
||||
error,
|
||||
workspace,
|
||||
sources = [],
|
||||
}) => {
|
||||
const assistantBackgroundColor = "bg-theme-bg-chat";
|
||||
|
||||
const PromptReply = ({ uuid, reply, pending, error, sources = [] }) => {
|
||||
if (!reply && sources.length === 0 && !pending && !error) return null;
|
||||
|
||||
if (pending) {
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-center items-end w-full ${assistantBackgroundColor}`}
|
||||
>
|
||||
<div className="py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col">
|
||||
<div className="flex gap-x-5">
|
||||
<WorkspaceProfileImage workspace={workspace} />
|
||||
<div className="mt-3 ml-5 dot-falling light:invert"></div>
|
||||
</div>
|
||||
<div className="flex justify-start w-full">
|
||||
<div className="py-4 pl-0 pr-4 flex flex-col md:max-w-[80%]">
|
||||
<div className="mt-3 ml-1 dot-falling light:invert"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -40,61 +26,32 @@ const PromptReply = ({
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-center items-end w-full ${assistantBackgroundColor}`}
|
||||
>
|
||||
<div className="py-6 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col">
|
||||
<div className="flex gap-x-5">
|
||||
<WorkspaceProfileImage workspace={workspace} />
|
||||
<span
|
||||
className={`inline-block p-2 rounded-lg bg-red-50 text-red-500`}
|
||||
>
|
||||
<Warning className="h-4 w-4 mb-1 inline-block" /> Could not
|
||||
respond to message.
|
||||
<span className="text-xs">Reason: {error || "unknown"}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-start w-full">
|
||||
<div className="py-4 pl-0 pr-4 flex flex-col md:max-w-[80%]">
|
||||
<span className="inline-block p-2 rounded-lg bg-red-50 text-red-500">
|
||||
<Warning className="h-4 w-4 mb-1 inline-block" /> Could not respond
|
||||
to message.
|
||||
<span className="text-xs">Reason: {error || "unknown"}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={uuid}
|
||||
className={`flex justify-center items-end w-full ${assistantBackgroundColor}`}
|
||||
>
|
||||
<div className="py-8 px-4 w-full flex gap-x-5 md:max-w-[80%] flex-col">
|
||||
<div className="flex gap-x-5">
|
||||
<WorkspaceProfileImage workspace={workspace} />
|
||||
<RenderAssistantChatContent
|
||||
key={`${uuid}-prompt-reply-content`}
|
||||
message={reply}
|
||||
messageId={uuid}
|
||||
/>
|
||||
</div>
|
||||
<div key={uuid} className="flex justify-start w-full">
|
||||
<div className="py-4 pl-0 pr-4 flex flex-col w-full">
|
||||
<RenderAssistantChatContent
|
||||
key={`${uuid}-prompt-reply-content`}
|
||||
message={reply}
|
||||
messageId={uuid}
|
||||
/>
|
||||
<Citations sources={sources} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function WorkspaceProfileImage({ workspace }) {
|
||||
if (!!workspace.pfpUrl) {
|
||||
return (
|
||||
<div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden">
|
||||
<img
|
||||
src={workspace.pfpUrl}
|
||||
alt="Workspace profile picture"
|
||||
className="absolute top-0 left-0 w-full h-full object-cover rounded-full bg-white"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <UserIcon user={{ uid: workspace.slug }} role="assistant" />;
|
||||
}
|
||||
|
||||
function RenderAssistantChatContent({ message, messageId }) {
|
||||
const contentRef = useRef("");
|
||||
const thoughtChainRef = useRef(null);
|
||||
@@ -140,7 +97,9 @@ function RenderAssistantChatContent({ message, messageId }) {
|
||||
)}
|
||||
<span
|
||||
className="break-words"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(contentRef.current) }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(renderMarkdown(contentRef.current)),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,22 +15,25 @@ export default function StatusResponse({ messages = [], isThinking = false }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center w-full">
|
||||
<div className="w-full max-w-[80%] flex flex-col">
|
||||
<div className=" w-full max-w-[800px]">
|
||||
<div className="flex justify-center w-full pr-4">
|
||||
<div className="w-full flex flex-col">
|
||||
<div className="w-full">
|
||||
<div
|
||||
onClick={handleExpandClick}
|
||||
style={{ borderRadius: "6px" }}
|
||||
className={`${!previousThoughts?.length ? "" : `${previousThoughts?.length ? "hover:bg-theme-sidebar-item-hover" : ""}`} items-start bg-theme-bg-chat-input py-2 px-4 flex gap-x-2`}
|
||||
style={{
|
||||
transition: "all 0.1s ease-in-out",
|
||||
borderRadius: "16px",
|
||||
}}
|
||||
className="relative bg-zinc-800 light:bg-slate-100 p-4"
|
||||
>
|
||||
<div className="w-7 h-7 flex justify-center flex-shrink-0 items-center">
|
||||
<div className="absolute top-4 left-4 w-[18px] h-[18px]">
|
||||
{isThinking ? (
|
||||
<video
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className="w-8 h-8 scale-150 transition-opacity duration-200 light:invert light:opacity-50"
|
||||
className="w-[18px] h-[18px] scale-[165%] transition-opacity duration-200 light:invert light:opacity-50"
|
||||
data-tooltip-id="agent-thinking"
|
||||
data-tooltip-content="Agent is thinking..."
|
||||
aria-label="Agent is thinking..."
|
||||
@@ -41,57 +44,53 @@ export default function StatusResponse({ messages = [], isThinking = false }) {
|
||||
<img
|
||||
src={AgentStatic}
|
||||
alt="Agent complete"
|
||||
className="w-6 h-6 transition-opacity duration-200 light:invert light:opacity-50"
|
||||
className="w-[18px] h-[18px] transition-opacity duration-200 light:invert light:opacity-50"
|
||||
data-tooltip-id="agent-thinking"
|
||||
data-tooltip-content="Agent has finished thinking"
|
||||
aria-label="Agent has finished thinking"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-in-out ${isExpanded ? "" : "max-h-6"}`}
|
||||
{previousThoughts?.length > 0 && (
|
||||
<button
|
||||
onClick={handleExpandClick}
|
||||
className="absolute top-4 right-4 border-none text-zinc-200 light:text-slate-800 transition-colors"
|
||||
data-tooltip-id="expand-cot"
|
||||
data-tooltip-content={
|
||||
isExpanded ? "Hide thought chain" : "Show thought chain"
|
||||
}
|
||||
aria-label={
|
||||
isExpanded ? "Hide thought chain" : "Show thought chain"
|
||||
}
|
||||
>
|
||||
<div className="text-theme-text-secondary font-mono leading-6">
|
||||
{!isExpanded ? (
|
||||
<span className="block w-full truncate mt-[2px]">
|
||||
{currentThought.content}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{previousThoughts.map((thought, index) => (
|
||||
<div
|
||||
key={`cot-${thought.uuid || index}`}
|
||||
className="mb-2"
|
||||
>
|
||||
{thought.content}
|
||||
</div>
|
||||
))}
|
||||
<div>{currentThought.content}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CaretDown
|
||||
className={`w-4 h-4 transform transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={`ml-[28px] mr-[26px] transition-[max-height] duration-300 ease-in-out origin-top ${isExpanded ? "" : "overflow-hidden max-h-[18px]"}`}
|
||||
>
|
||||
<div className="text-zinc-200 light:text-slate-800 font-mono text-sm leading-[18px]">
|
||||
{!isExpanded ? (
|
||||
<span className="block w-full truncate">
|
||||
{currentThought.content}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{previousThoughts.map((thought, index) => (
|
||||
<div
|
||||
key={`cot-${thought.uuid || index}`}
|
||||
className="mb-2"
|
||||
>
|
||||
{thought.content}
|
||||
</div>
|
||||
))}
|
||||
<div>{currentThought.content}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{previousThoughts?.length > 0 && (
|
||||
<button
|
||||
onClick={handleExpandClick}
|
||||
data-tooltip-id="expand-cot"
|
||||
data-tooltip-content={
|
||||
isExpanded ? "Hide thought chain" : "Show thought chain"
|
||||
}
|
||||
className="border-none text-theme-text-secondary hover:text-theme-text-primary transition-colors p-1 rounded-full hover:bg-theme-sidebar-item-hover"
|
||||
aria-label={
|
||||
isExpanded ? "Hide thought chain" : "Show thought chain"
|
||||
}
|
||||
>
|
||||
<CaretDown
|
||||
className={`w-4 h-4 transform transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,50 +141,63 @@ export const ThoughtChainComponent = forwardRef(
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-start items-end transition-all duration-200 w-full md:max-w-[800px]">
|
||||
<div className="pb-2 w-full flex gap-x-5 flex-col relative">
|
||||
<div
|
||||
style={{
|
||||
transition: "all 0.1s ease-in-out",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
className={`${isExpanded ? "" : `${canExpand ? "hover:bg-theme-sidebar-item-hover" : ""}`} items-start bg-theme-bg-chat-input py-2 px-4 flex gap-x-2`}
|
||||
>
|
||||
<div className="flex justify-center w-full">
|
||||
<div className="w-full flex flex-col">
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={`w-7 h-7 flex justify-center flex-shrink-0 ${!isExpanded ? "items-center" : "items-start pt-[2px]"}`}
|
||||
style={{
|
||||
transition: "all 0.1s ease-in-out",
|
||||
borderRadius: "16px",
|
||||
}}
|
||||
className="relative bg-zinc-800 light:bg-slate-100 p-4"
|
||||
>
|
||||
{isThinking || isComplete ? (
|
||||
<>
|
||||
<video
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className={`w-7 h-7 transition-opacity duration-200 light:invert light:opacity-50 ${isThinking ? "opacity-100" : "opacity-0 hidden"}`}
|
||||
data-tooltip-id="cot-thinking"
|
||||
data-tooltip-content="Model is thinking..."
|
||||
aria-label="Model is thinking..."
|
||||
>
|
||||
<source src={ThinkingAnimation} type="video/webm" />
|
||||
</video>
|
||||
<img
|
||||
src={ThinkingStatic}
|
||||
alt="Thinking complete"
|
||||
className={`w-6 h-6 transition-opacity duration-200 light:invert light:opacity-50 ${!isThinking && isComplete ? "opacity-100" : "opacity-0 hidden"}`}
|
||||
data-tooltip-id="cot-thinking"
|
||||
data-tooltip-content="Model has finished thinking"
|
||||
aria-label="Model has finished thinking"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={`overflow-hidden transition-all transform duration-300 ease-in-out origin-top ${isExpanded ? "" : "max-h-6"}`}
|
||||
>
|
||||
<div
|
||||
className={`text-theme-text-secondary font-mono leading-6 ${isExpanded ? "-ml-[5.5px] -mt-[4px]" : "mt-[2px]"}`}
|
||||
<div className="absolute top-4 left-4 w-[18px] h-[18px]">
|
||||
{isThinking || isComplete ? (
|
||||
<>
|
||||
<video
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className={`w-[18px] h-[18px] scale-[115%] transition-opacity duration-200 light:invert light:opacity-50 ${isThinking ? "opacity-100" : "opacity-0 hidden"}`}
|
||||
data-tooltip-id="cot-thinking"
|
||||
data-tooltip-content="Model is thinking..."
|
||||
aria-label="Model is thinking..."
|
||||
>
|
||||
<source src={ThinkingAnimation} type="video/webm" />
|
||||
</video>
|
||||
<img
|
||||
src={ThinkingStatic}
|
||||
alt="Thinking complete"
|
||||
className={`w-[18px] h-[18px] transition-opacity duration-200 light:invert light:opacity-50 ${!isThinking && isComplete ? "opacity-100" : "opacity-0 hidden"}`}
|
||||
data-tooltip-id="cot-thinking"
|
||||
data-tooltip-content="Model has finished thinking"
|
||||
aria-label="Model has finished thinking"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{canExpand && (
|
||||
<button
|
||||
onClick={handleExpandClick}
|
||||
className="absolute top-4 right-4 border-none text-zinc-200 light:text-slate-800 transition-colors"
|
||||
data-tooltip-id="expand-cot"
|
||||
data-tooltip-content={
|
||||
isExpanded ? "Hide thought chain" : "Show thought chain"
|
||||
}
|
||||
aria-label={
|
||||
isExpanded ? "Hide thought chain" : "Show thought chain"
|
||||
}
|
||||
>
|
||||
<CaretDown
|
||||
className={`w-4 h-4 transform transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={`ml-[28px] mr-[26px] transition-[max-height] duration-300 ease-in-out origin-top ${isExpanded ? "" : "overflow-hidden max-h-[18px]"}`}
|
||||
>
|
||||
<div className="text-zinc-200 light:text-slate-800 font-mono text-sm leading-[18px] [&_p]:m-0">
|
||||
<span
|
||||
className={`block w-full ${!isExpanded ? "truncate" : ""}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -198,25 +211,6 @@ export const ThoughtChainComponent = forwardRef(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{canExpand ? (
|
||||
<button
|
||||
onClick={handleExpandClick}
|
||||
data-tooltip-id="expand-cot"
|
||||
data-tooltip-content={
|
||||
isExpanded ? "Hide thought chain" : "Show thought chain"
|
||||
}
|
||||
className="border-none text-theme-text-secondary hover:text-theme-text-primary transition-colors p-1 rounded-full hover:bg-theme-sidebar-item-hover"
|
||||
aria-label={
|
||||
isExpanded ? "Hide thought chain" : "Show thought chain"
|
||||
}
|
||||
>
|
||||
<CaretDown
|
||||
className={`w-4 h-4 transform transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { CaretDown, Check, X, Hammer } from "@phosphor-icons/react";
|
||||
import AgentSkillWhitelist from "@/models/agentSkillWhitelist";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ToolApprovalRequest({
|
||||
requestId,
|
||||
skillName,
|
||||
payload = {},
|
||||
description = null,
|
||||
timeoutMs = null,
|
||||
websocket,
|
||||
onResponse,
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [responded, setResponded] = useState(false);
|
||||
const [approved, setApproved] = useState(null);
|
||||
const [alwaysAllow, setAlwaysAllow] = useState(false);
|
||||
const [timeRemaining, setTimeRemaining] = useState(timeoutMs);
|
||||
const startTimeRef = useRef(null);
|
||||
const hasPayload = payload && Object.keys(payload).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!timeoutMs || responded) return;
|
||||
if (startTimeRef.current === null) {
|
||||
startTimeRef.current = Date.now();
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
const elapsed = Date.now() - startTimeRef.current;
|
||||
const remaining = Math.max(0, timeoutMs - elapsed);
|
||||
setTimeRemaining(remaining);
|
||||
|
||||
if (remaining <= 0) {
|
||||
clearInterval(intervalId);
|
||||
handleTimeout();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [timeoutMs, responded]);
|
||||
|
||||
function handleTimeout() {
|
||||
if (responded) return;
|
||||
setResponded(true);
|
||||
setApproved(false);
|
||||
onResponse?.(false);
|
||||
}
|
||||
|
||||
async function handleResponse(isApproved) {
|
||||
if (responded) return;
|
||||
|
||||
setResponded(true);
|
||||
setApproved(isApproved);
|
||||
|
||||
// If user approved and checked "Always allow", add to whitelist
|
||||
if (isApproved && alwaysAllow) {
|
||||
await AgentSkillWhitelist.addToWhitelist(skillName);
|
||||
}
|
||||
|
||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||
websocket.send(
|
||||
JSON.stringify({
|
||||
type: "toolApprovalResponse",
|
||||
requestId,
|
||||
approved: isApproved,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onResponse?.(isApproved);
|
||||
}
|
||||
|
||||
const progressPercent = timeoutMs ? (timeRemaining / timeoutMs) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center w-full my-1 pr-4">
|
||||
<div className="w-full flex flex-col">
|
||||
<div className="w-full">
|
||||
<div
|
||||
style={{
|
||||
transition: "all 0.1s ease-in-out",
|
||||
borderRadius: "16px",
|
||||
}}
|
||||
className="relative bg-zinc-800 light:bg-slate-100 p-4 pb-2 flex flex-col gap-y-1 overflow-hidden"
|
||||
>
|
||||
<ToolApprovalHeader
|
||||
skillName={skillName}
|
||||
hasPayload={hasPayload}
|
||||
isExpanded={isExpanded}
|
||||
setIsExpanded={setIsExpanded}
|
||||
/>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{description && (
|
||||
<span className="text-white/60 light:text-slate-700 font-medium font-mono text-xs">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
<ToolApprovalPayload payload={payload} isExpanded={isExpanded} />
|
||||
<ToolApprovalResponseOption
|
||||
approved={approved}
|
||||
skillName={skillName}
|
||||
alwaysAllow={alwaysAllow}
|
||||
setAlwaysAllow={setAlwaysAllow}
|
||||
onApprove={() => handleResponse(true)}
|
||||
onReject={() => handleResponse(false)}
|
||||
/>
|
||||
<ToolApprovalResponseMessage approved={approved} />
|
||||
</div>
|
||||
{timeoutMs && !responded && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-zinc-700 light:bg-slate-300">
|
||||
<div
|
||||
className="h-full bg-sky-500 light:bg-sky-600 transition-none"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolApprovalHeader({
|
||||
skillName,
|
||||
hasPayload,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hammer size={16} />
|
||||
<div className="text-white/80 light:text-slate-900 font-medium text-sm flex gap-x-1">
|
||||
{t("chat_window.agent_invocation.model_wants_to_call")}
|
||||
<span className="font-semibold text-sky-400 light:text-sky-600">
|
||||
{skillName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{hasPayload && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="absolute top-4 right-4 border-none"
|
||||
aria-label={isExpanded ? "Hide details" : "Show details"}
|
||||
>
|
||||
<CaretDown
|
||||
className={`w-4 h-4 transform transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolApprovalPayload({ payload, isExpanded }) {
|
||||
const hasPayload = payload && Object.keys(payload).length > 0;
|
||||
if (!hasPayload || !isExpanded) return null;
|
||||
|
||||
function formatPayload(data) {
|
||||
if (typeof data === "string") return data;
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 bg-zinc-900/50 light:bg-slate-200/50 rounded-lg overflow-x-auto">
|
||||
<pre className="text-xs text-zinc-300 light:text-slate-700 font-mono whitespace-pre-wrap break-words">
|
||||
{formatPayload(payload)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolApprovalResponseOption({
|
||||
approved,
|
||||
skillName,
|
||||
alwaysAllow,
|
||||
setAlwaysAllow,
|
||||
onApprove,
|
||||
onReject,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (approved !== null) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mt-1 pb-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApprove}
|
||||
className="border-none transition-all duration-300 bg-white text-black hover:opacity-60 px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
{t("chat_window.agent_invocation.approve")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReject}
|
||||
className="border-none text-white light:text-slate-900 text-sm font-medium w-[70px] h-9 rounded-lg hover:bg-white/5 light:hover:bg-slate-300"
|
||||
>
|
||||
{t("chat_window.agent_invocation.reject")}
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer text-white/60 light:text-slate-600 text-xs hover:text-white/80 light:hover:text-slate-800 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alwaysAllow}
|
||||
onChange={(e) => setAlwaysAllow(e.target.checked)}
|
||||
className="w-3.5 h-3.5 rounded border-white/20 bg-transparent cursor-pointer"
|
||||
/>
|
||||
<span>
|
||||
{t("chat_window.agent_invocation.always_allow", { skillName })}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolApprovalResponseMessage({ approved }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (approved === null) return null;
|
||||
if (approved === false) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-red-400 light:text-red-500">
|
||||
<X size={16} weight="bold" />
|
||||
<span>{t("chat_window.agent_invocation.tool_call_was_rejected")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-green-400 light:text-green-500">
|
||||
<Check size={16} weight="bold" />
|
||||
<span>{t("chat_window.agent_invocation.tool_call_was_approved")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import HistoricalMessage from "./HistoricalMessage";
|
||||
import PromptReply from "./PromptReply";
|
||||
import StatusResponse from "./StatusResponse";
|
||||
import ToolApprovalRequest from "./ToolApprovalRequest";
|
||||
import { useManageWorkspaceModal } from "../../../Modals/ManageWorkspace";
|
||||
import ManageWorkspace from "../../../Modals/ManageWorkspace";
|
||||
import { ArrowDown } from "@phosphor-icons/react";
|
||||
@@ -20,7 +21,6 @@ import paths from "@/utils/paths";
|
||||
import Appearance from "@/models/appearance";
|
||||
import useTextSize from "@/hooks/useTextSize";
|
||||
import useChatHistoryScrollHandle from "@/hooks/useChatHistoryScrollHandle";
|
||||
import { useChatMessageAlignment } from "@/hooks/useChatMessageAlignment";
|
||||
import { ThoughtExpansionProvider } from "./ThoughtContainer";
|
||||
|
||||
export default forwardRef(function (
|
||||
@@ -30,6 +30,7 @@ export default forwardRef(function (
|
||||
sendCommand,
|
||||
updateHistory,
|
||||
regenerateAssistantMessage,
|
||||
websocket = null,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
@@ -42,7 +43,6 @@ export default forwardRef(function (
|
||||
const isStreaming = history[history.length - 1]?.animate;
|
||||
const { showScrollbar } = Appearance.getSettings();
|
||||
const { textSizeClass } = useTextSize();
|
||||
const { getMessageAlignment } = useChatMessageAlignment();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserScrolling && (isAtBottom || isStreaming)) {
|
||||
@@ -52,7 +52,7 @@ export default forwardRef(function (
|
||||
|
||||
const handleScroll = (e) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
const isBottom = scrollHeight - scrollTop === clientHeight;
|
||||
const isBottom = scrollHeight - scrollTop - clientHeight < 2;
|
||||
|
||||
// Detect if this is a user-initiated scroll
|
||||
if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) {
|
||||
@@ -98,10 +98,28 @@ export default forwardRef(function (
|
||||
chatId,
|
||||
role,
|
||||
attachments = [],
|
||||
saveOnly = false,
|
||||
}) => {
|
||||
if (!editedMessage) return; // Don't save empty edits.
|
||||
|
||||
// if the edit was a user message, we will auto-regenerate the response and delete all
|
||||
// "Save" on a user message: update the prompt text without regenerating
|
||||
if (role === "user" && saveOnly) {
|
||||
const updatedHistory = [...history];
|
||||
const targetIdx = history.findIndex((msg) => msg.chatId === chatId);
|
||||
if (targetIdx < 0) return;
|
||||
updatedHistory[targetIdx].content = editedMessage;
|
||||
updateHistory(updatedHistory);
|
||||
await Workspace.updateChat(
|
||||
workspace.slug,
|
||||
threadSlug,
|
||||
chatId,
|
||||
editedMessage,
|
||||
"user"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// "Submit" on a user message: auto-regenerate the response and delete all
|
||||
// messages post modified message
|
||||
if (role === "user") {
|
||||
// remove all messages after the edited message
|
||||
@@ -133,7 +151,7 @@ export default forwardRef(function (
|
||||
if (targetIdx < 0) return;
|
||||
updatedHistory[targetIdx].content = editedMessage;
|
||||
updateHistory(updatedHistory);
|
||||
await Workspace.updateChatResponse(
|
||||
await Workspace.updateChat(
|
||||
workspace.slug,
|
||||
threadSlug,
|
||||
chatId,
|
||||
@@ -163,7 +181,7 @@ export default forwardRef(function (
|
||||
regenerateAssistantMessage,
|
||||
saveEditedMessage,
|
||||
forkThread,
|
||||
getMessageAlignment,
|
||||
websocket,
|
||||
}),
|
||||
[
|
||||
workspace,
|
||||
@@ -171,6 +189,7 @@ export default forwardRef(function (
|
||||
regenerateAssistantMessage,
|
||||
saveEditedMessage,
|
||||
forkThread,
|
||||
websocket,
|
||||
]
|
||||
);
|
||||
const lastMessageInfo = useMemo(() => getLastMessageInfo(history), [history]);
|
||||
@@ -191,36 +210,38 @@ export default forwardRef(function (
|
||||
return (
|
||||
<ThoughtExpansionProvider>
|
||||
<div
|
||||
className={`markdown text-white/80 light:text-theme-text-primary font-light ${textSizeClass} h-full md:h-[83%] pb-[100px] pt-6 md:pt-0 md:pb-20 md:mx-0 overflow-y-scroll flex flex-col justify-start ${showScrollbar ? "show-scrollbar" : "no-scroll"}`}
|
||||
className={`markdown text-white/80 light:text-theme-text-primary font-light ${textSizeClass} h-full md:h-[83%] pb-[100px] pt-6 md:pt-0 md:pb-20 md:mx-0 overflow-y-scroll flex flex-col items-center justify-start ${showScrollbar ? "show-scrollbar" : "no-scroll"}`}
|
||||
id="chat-history"
|
||||
ref={chatHistoryRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{compiledHistory.map((item, index) =>
|
||||
Array.isArray(item) ? renderStatusResponse(item, index) : item
|
||||
)}
|
||||
<div className="w-full max-w-[750px]">
|
||||
{compiledHistory.map((item, index) =>
|
||||
Array.isArray(item) ? renderStatusResponse(item, index) : item
|
||||
)}
|
||||
</div>
|
||||
{showing && (
|
||||
<ManageWorkspace
|
||||
hideModal={hideModal}
|
||||
providedSlug={workspace.slug}
|
||||
/>
|
||||
)}
|
||||
{!isAtBottom && (
|
||||
<div className="fixed bottom-40 right-10 md:right-20 z-50 cursor-pointer animate-pulse">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className="p-1 rounded-full border border-white/10 bg-white/10 hover:bg-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
scrollToBottom(isStreaming ? false : true);
|
||||
setIsUserScrolling(false);
|
||||
}}
|
||||
>
|
||||
<ArrowDown weight="bold" className="text-white/60 w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
{!isAtBottom && (
|
||||
<div className="absolute bottom-40 right-10 z-50 cursor-pointer animate-pulse">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className="p-1 rounded-full border border-white/10 bg-white/10 hover:bg-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
scrollToBottom(isStreaming ? false : true);
|
||||
setIsUserScrolling(false);
|
||||
}}
|
||||
>
|
||||
<ArrowDown weight="bold" className="text-white/60 w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ThoughtExpansionProvider>
|
||||
);
|
||||
});
|
||||
@@ -245,7 +266,7 @@ const getLastMessageInfo = (history) => {
|
||||
* @param {Function} param0.regenerateAssistantMessage - The function to regenerate the assistant message.
|
||||
* @param {Function} param0.saveEditedMessage - The function to save the edited message.
|
||||
* @param {Function} param0.forkThread - The function to fork the thread.
|
||||
* @param {Function} param0.getMessageAlignment - The function to get the alignment of the message (returns class).
|
||||
* @param {WebSocket} param0.websocket - The active websocket connection for agent communication.
|
||||
* @returns {Array} The compiled history of messages.
|
||||
*/
|
||||
function buildMessages({
|
||||
@@ -254,7 +275,7 @@ function buildMessages({
|
||||
regenerateAssistantMessage,
|
||||
saveEditedMessage,
|
||||
forkThread,
|
||||
getMessageAlignment,
|
||||
websocket,
|
||||
}) {
|
||||
return history.reduce((acc, props, index) => {
|
||||
const isLastBotReply =
|
||||
@@ -269,10 +290,23 @@ function buildMessages({
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (props.type === "rechartVisualize" && !!props.content) {
|
||||
if (props.type === "toolApprovalRequest") {
|
||||
acc.push(
|
||||
<Chartable key={props.uuid} workspace={workspace} props={props} />
|
||||
<ToolApprovalRequest
|
||||
key={`tool-approval-${props.requestId}`}
|
||||
requestId={props.requestId}
|
||||
skillName={props.skillName}
|
||||
payload={props.payload}
|
||||
description={props.description}
|
||||
timeoutMs={props.timeoutMs}
|
||||
websocket={websocket}
|
||||
/>
|
||||
);
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (props.type === "rechartVisualize" && !!props.content) {
|
||||
acc.push(<Chartable key={props.uuid} props={props} />);
|
||||
} else if (isLastBotReply && props.animate) {
|
||||
acc.push(
|
||||
<PromptReply
|
||||
@@ -282,7 +316,6 @@ function buildMessages({
|
||||
pending={props.pending}
|
||||
sources={props.sources}
|
||||
error={props.error}
|
||||
workspace={workspace}
|
||||
closed={props.closed}
|
||||
/>
|
||||
);
|
||||
@@ -304,7 +337,6 @@ function buildMessages({
|
||||
saveEditedMessage={saveEditedMessage}
|
||||
forkThread={forkThread}
|
||||
metrics={props.metrics}
|
||||
alignmentCls={getMessageAlignment?.(props.role)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Set the tooltips for the chat container in bulk.
|
||||
@@ -16,6 +17,8 @@ import { createPortal } from "react-dom";
|
||||
* @returns
|
||||
*/
|
||||
export function ChatTooltips() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
@@ -90,6 +93,19 @@ export function ChatTooltips() {
|
||||
delayShow={300}
|
||||
className="tooltip !text-xs"
|
||||
/>
|
||||
<Tooltip
|
||||
id="attach-item-btn"
|
||||
place="top"
|
||||
delayShow={300}
|
||||
className="tooltip !text-xs"
|
||||
/>
|
||||
<Tooltip
|
||||
id="agent-skill-disabled-tooltip"
|
||||
place="top"
|
||||
delayShow={300}
|
||||
className="tooltip !text-xs z-99"
|
||||
content={t("chat_window.agent_skills_disabled_in_session")}
|
||||
/>
|
||||
<DocumentLevelTooltip />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -34,14 +34,6 @@ export default function AvailableAgentsButton({ showing, setShowAgents }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AbilityTag({ text }) {
|
||||
return (
|
||||
<div className="px-2 bg-theme-action-menu-item-hover text-theme-text-secondary text-xs w-fit rounded-sm">
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AvailableAgents({
|
||||
showing,
|
||||
setShowing,
|
||||
@@ -109,26 +101,6 @@ export function AvailableAgents({
|
||||
<b>{t("chat_window.at_agent")}</b>
|
||||
{t("chat_window.default_agent_description")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<AbilityTag text="rag-search" />
|
||||
<AbilityTag text="web-scraping" />
|
||||
<AbilityTag text="web-browsing" />
|
||||
<AbilityTag text="save-file-to-browser" />
|
||||
<AbilityTag text="list-documents" />
|
||||
<AbilityTag text="summarize-document" />
|
||||
<AbilityTag text="chart-generation" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={true}
|
||||
className="w-full rounded-xl flex flex-col justify-start group"
|
||||
>
|
||||
<div className="w-full flex-col text-center flex pointer-events-none">
|
||||
<div className="text-theme-text-secondary text-xs italic">
|
||||
{t("chat_window.custom_agents_coming_soon")}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PaperclipHorizontal } from "@phosphor-icons/react";
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
@@ -88,20 +88,26 @@ export default function AttachItem({
|
||||
<>
|
||||
<button
|
||||
id="attach-item-btn"
|
||||
data-tooltip-id="tooltip-attach-item-btn"
|
||||
data-tooltip-id={
|
||||
showTooltip ? "tooltip-attach-item-btn" : "attach-item-btn"
|
||||
}
|
||||
data-tooltip-content={
|
||||
!showTooltip ? t("chat_window.attach_file") : undefined
|
||||
}
|
||||
aria-label={t("chat_window.attach_file")}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
onPointerEnter={fetchFiles}
|
||||
className={`border-none relative flex justify-center items-center opacity-60 hover:opacity-100 light:opacity-100 light:hover:opacity-60 cursor-pointer`}
|
||||
className="group border-none relative flex justify-center items-center cursor-pointer w-6 h-6 rounded-full hover:bg-zinc-700 light:hover:bg-slate-200"
|
||||
>
|
||||
<div className="relative">
|
||||
<PaperclipHorizontal
|
||||
color="var(--theme-sidebar-footer-icon-fill)"
|
||||
className="w-[20px] h-[20px] pointer-events-none text-white rotate-90 -scale-y-100"
|
||||
<Plus
|
||||
size={18}
|
||||
className="pointer-events-none text-zinc-300 light:text-slate-600 group-hover:text-white light:group-hover:text-slate-600 shrink-0"
|
||||
weight="bold"
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<div className="absolute -top-2 right-[1%] bg-white text-black light:invert text-[8px] rounded-full px-1 flex items-center justify-center">
|
||||
<div className="absolute -top-2.5 -right-2 bg-white text-black light:invert text-[8px] rounded-full px-1 flex items-center justify-center">
|
||||
{files.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import useGetProviderModels, {
|
||||
DISABLED_PROVIDERS,
|
||||
} from "@/hooks/useGetProvidersModels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ChatModelSelection({
|
||||
provider,
|
||||
@@ -11,110 +10,81 @@ export default function ChatModelSelection({
|
||||
}) {
|
||||
const { defaultModels, customModels, loading } =
|
||||
useGetProviderModels(provider);
|
||||
const { t } = useTranslation();
|
||||
if (DISABLED_PROVIDERS.includes(provider)) return null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="name" className="block input-label">
|
||||
{t("chat_window.workspace_llm_manager.available_models", {
|
||||
provider,
|
||||
})}
|
||||
</label>
|
||||
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
|
||||
{t(
|
||||
"chat_window.workspace_llm_manager.available_models_description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
required={true}
|
||||
disabled={true}
|
||||
className="border-theme-modal-border border border-solid bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
|
||||
>
|
||||
<option disabled={true} selected={true}>
|
||||
-- waiting for models --
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<select
|
||||
required={true}
|
||||
disabled={true}
|
||||
className="bg-zinc-900 light:bg-white text-white light:text-slate-900 text-sm rounded-lg h-8 w-full px-2.5 outline-none border border-zinc-900 light:border-slate-400 cursor-not-allowed"
|
||||
>
|
||||
<option disabled={true} selected={true}>
|
||||
-- waiting for models --
|
||||
</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="name" className="block input-label">
|
||||
{t("chat_window.workspace_llm_manager.available_models", {
|
||||
provider,
|
||||
<select
|
||||
id="workspace-llm-model-select"
|
||||
required={true}
|
||||
value={selectedLLMModel}
|
||||
onChange={(e) => {
|
||||
setHasChanges(true);
|
||||
setSelectedLLMModel(e.target.value);
|
||||
}}
|
||||
className="bg-zinc-900 light:bg-white text-white light:text-slate-900 text-sm rounded-lg h-8 w-full px-2.5 outline-none border border-zinc-900 light:border-slate-400 cursor-pointer"
|
||||
>
|
||||
{defaultModels.length > 0 && (
|
||||
<optgroup label="General models">
|
||||
{defaultModels.map((model) => {
|
||||
return (
|
||||
<option
|
||||
key={model}
|
||||
value={model}
|
||||
selected={selectedLLMModel === model}
|
||||
>
|
||||
{model}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</label>
|
||||
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
|
||||
{t("chat_window.workspace_llm_manager.available_models_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<select
|
||||
id="workspace-llm-model-select"
|
||||
required={true}
|
||||
value={selectedLLMModel}
|
||||
onChange={(e) => {
|
||||
setHasChanges(true);
|
||||
setSelectedLLMModel(e.target.value);
|
||||
}}
|
||||
className="border-theme-modal-border border border-solid bg-theme-settings-input-bg text-white text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
|
||||
>
|
||||
{defaultModels.length > 0 && (
|
||||
<optgroup label="General models">
|
||||
{defaultModels.map((model) => {
|
||||
return (
|
||||
<option
|
||||
key={model}
|
||||
value={model}
|
||||
selected={selectedLLMModel === model}
|
||||
>
|
||||
{model}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</optgroup>
|
||||
)}
|
||||
{Array.isArray(customModels) && customModels.length > 0 && (
|
||||
<optgroup label="Discovered models">
|
||||
{customModels.map((model) => {
|
||||
return (
|
||||
</optgroup>
|
||||
)}
|
||||
{Array.isArray(customModels) && customModels.length > 0 && (
|
||||
<optgroup label="Discovered models">
|
||||
{customModels.map((model) => {
|
||||
return (
|
||||
<option
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
selected={selectedLLMModel === model.id}
|
||||
>
|
||||
{model.id}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</optgroup>
|
||||
)}
|
||||
{/* For providers like TogetherAi where we partition model by creator entity. */}
|
||||
{!Array.isArray(customModels) && Object.keys(customModels).length > 0 && (
|
||||
<>
|
||||
{Object.entries(customModels).map(([organization, models]) => (
|
||||
<optgroup key={organization} label={organization}>
|
||||
{models.map((model) => (
|
||||
<option
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
selected={selectedLLMModel === model.id}
|
||||
>
|
||||
{model.id}
|
||||
{model.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</optgroup>
|
||||
)}
|
||||
{/* For providers like TogetherAi where we partition model by creator entity. */}
|
||||
{!Array.isArray(customModels) &&
|
||||
Object.keys(customModels).length > 0 && (
|
||||
<>
|
||||
{Object.entries(customModels).map(([organization, models]) => (
|
||||
<optgroup key={organization} label={organization}>
|
||||
{models.map((model) => (
|
||||
<option
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
selected={selectedLLMModel === model.id}
|
||||
>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</optgroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MagnifyingGlass } from "@phosphor-icons/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function LLMSelectorSidePanel({
|
||||
@@ -9,31 +10,42 @@ export default function LLMSelectorSidePanel({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="w-[40%] h-full flex flex-col gap-y-1 border-r-2 border-theme-modal-border py-2 px-[5px]">
|
||||
<input
|
||||
id="llm-search-input"
|
||||
type="search"
|
||||
placeholder={t("chat_window.workspace_llm_manager.search")}
|
||||
onChange={onSearchChange}
|
||||
className="search-input bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder outline-none text-sm rounded-lg px-2 py-2 w-full h-[32px] border-theme-modal-border border border-solid"
|
||||
/>
|
||||
<div className="flex flex-col gap-y-2 overflow-y-scroll ">
|
||||
<div className="w-[40%] h-full flex flex-col gap-4 p-2 border-r border-zinc-700 light:border-slate-300">
|
||||
<div className="relative shrink-0 mx-2">
|
||||
<MagnifyingGlass
|
||||
size={14}
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400 light:text-slate-400"
|
||||
weight="bold"
|
||||
/>
|
||||
<input
|
||||
id="llm-search-input"
|
||||
type="search"
|
||||
placeholder={t("chat_window.workspace_llm_manager.search")}
|
||||
onChange={onSearchChange}
|
||||
className="bg-zinc-900 light:bg-white text-white light:text-slate-900 placeholder:text-zinc-500 light:placeholder:text-slate-400 text-sm rounded-lg pl-8 pr-2.5 h-8 w-full outline-none border border-zinc-900 light:border-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 overflow-y-auto min-h-0 flex-1">
|
||||
{availableProviders.map((llm) => (
|
||||
<button
|
||||
key={llm.value}
|
||||
type="button"
|
||||
data-llm-value={llm.value}
|
||||
className={`border-none hover:cursor-pointer hover:bg-theme-checklist-item-bg-hover flex gap-x-2 items-center p-2 rounded-md ${selectedLLMProvider === llm.value ? "bg-theme-checklist-item-bg" : ""}`}
|
||||
className={`border-none cursor-pointer flex gap-2 items-center px-2.5 py-1.5 rounded-md transition-colors ${
|
||||
selectedLLMProvider === llm.value
|
||||
? "bg-zinc-700 light:bg-slate-200"
|
||||
: "hover:bg-zinc-700/50 light:hover:bg-slate-100 bg-transparent"
|
||||
}`}
|
||||
onClick={() => onProviderClick(llm.value)}
|
||||
>
|
||||
<img
|
||||
src={llm.logo}
|
||||
alt={`${llm.name} logo`}
|
||||
className="w-6 h-6 rounded-md"
|
||||
className="w-6 h-6 rounded"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-xs text-theme-text-primary">{llm.name}</div>
|
||||
</div>
|
||||
<span className="text-sm text-white light:text-slate-900">
|
||||
{llm.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import ModalWrapper from "@/components/ModalWrapper";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { X, WarningCircle } from "@phosphor-icons/react";
|
||||
import System from "@/models/system";
|
||||
import showToast from "@/utils/toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -93,17 +93,23 @@ export function NoSetupWarning({ showing, onSetupClick }) {
|
||||
if (!showing) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSetupClick}
|
||||
className="border border-blue-500 rounded-lg p-2 flex flex-col items-center gap-y-2 bg-blue-600/10 text-blue-600 hover:bg-blue-600/20 transition-all duration-300"
|
||||
>
|
||||
<p className="text-sm text-center">
|
||||
<b>{t("chat_window.workspace_llm_manager.missing_credentials")}</b>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<WarningCircle
|
||||
size={16}
|
||||
className="text-white light:text-slate-800 shrink-0 mt-0.5"
|
||||
/>
|
||||
<p className="text-[13px] text-white light:text-slate-800 leading-5">
|
||||
{t("chat_window.workspace_llm_manager.missing_credentials")}{" "}
|
||||
<span
|
||||
onClick={onSetupClick}
|
||||
className="text-sky-400 font-semibold cursor-pointer hover:underline"
|
||||
role="button"
|
||||
>
|
||||
{t(
|
||||
"chat_window.workspace_llm_manager.missing_credentials_description"
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-center">
|
||||
{t("chat_window.workspace_llm_manager.missing_credentials_description")}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ import showToast from "@/utils/toast";
|
||||
import Workspace from "@/models/workspace";
|
||||
import System from "@/models/system";
|
||||
|
||||
export default function LLMSelectorModal({ workspaceSlug = null }) {
|
||||
export default function LLMSelectorModal({
|
||||
workspaceSlug = null,
|
||||
initialProvider = null,
|
||||
}) {
|
||||
const { slug: urlSlug } = useParams();
|
||||
const slug = urlSlug ?? workspaceSlug;
|
||||
const { t } = useTranslation();
|
||||
@@ -36,14 +39,22 @@ export default function LLMSelectorModal({ workspaceSlug = null }) {
|
||||
setLoading(true);
|
||||
Promise.all([Workspace.bySlug(slug), System.keys()])
|
||||
.then(([workspace, systemSettings]) => {
|
||||
const selectedLLMProvider =
|
||||
const savedProvider =
|
||||
workspace.chatProvider ?? systemSettings.LLMProvider;
|
||||
const selectedLLMModel = workspace.chatModel ?? systemSettings.LLMModel;
|
||||
const savedModel = workspace.chatModel ?? systemSettings.LLMModel;
|
||||
const providerToSelect = initialProvider ?? savedProvider;
|
||||
|
||||
setSettings(systemSettings);
|
||||
setSelectedLLMProvider(selectedLLMProvider);
|
||||
autoScrollToSelectedLLMProvider(selectedLLMProvider);
|
||||
setSelectedLLMModel(selectedLLMModel);
|
||||
setSelectedLLMProvider(providerToSelect);
|
||||
autoScrollToSelectedLLMProvider(providerToSelect);
|
||||
setSelectedLLMModel(savedModel);
|
||||
|
||||
if (initialProvider && initialProvider !== savedProvider) {
|
||||
setHasChanges(true);
|
||||
setMissingCredentials(
|
||||
hasMissingCredentials(systemSettings, initialProvider)
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [slug]);
|
||||
@@ -87,14 +98,18 @@ export default function LLMSelectorModal({ workspaceSlug = null }) {
|
||||
}
|
||||
}
|
||||
|
||||
const providerName =
|
||||
WORKSPACE_LLM_PROVIDERS.find((p) => p.value === selectedLLMProvider)
|
||||
?.name || selectedLLMProvider;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
id="llm-selector-modal"
|
||||
className="w-full h-[500px] p-0 overflow-y-scroll flex flex-col items-center justify-center"
|
||||
className="w-full h-[388px] flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<PreLoader size={12} />
|
||||
<p className="text-theme-text-secondary text-sm mt-2">
|
||||
<p className="text-zinc-400 light:text-slate-500 text-sm">
|
||||
{t("chat_window.workspace_llm_manager.loading_workspace_settings")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -102,17 +117,36 @@ export default function LLMSelectorModal({ workspaceSlug = null }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="llm-selector-modal"
|
||||
className="w-full h-[500px] p-0 overflow-y-scroll flex"
|
||||
>
|
||||
<div id="llm-selector-modal" className="w-full h-[388px] flex">
|
||||
<LLMSelectorSidePanel
|
||||
availableProviders={availableProviders}
|
||||
selectedLLMProvider={selectedLLMProvider}
|
||||
onSearchChange={handleSearch}
|
||||
onProviderClick={handleProviderSelection}
|
||||
/>
|
||||
<div className="w-[60%] h-full px-2 flex flex-col gap-y-2">
|
||||
<div className="w-[60%] h-full p-[18px] flex flex-col gap-2.5">
|
||||
<div className="flex flex-col gap-[15px]">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium text-white light:text-slate-800">
|
||||
{t("chat_window.workspace_llm_manager.available_models", {
|
||||
provider: providerName,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs font-medium text-zinc-400 light:text-slate-500">
|
||||
{t(
|
||||
"chat_window.workspace_llm_manager.available_models_description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!missingCredentials && (
|
||||
<ChatModelSelection
|
||||
provider={selectedLLMProvider}
|
||||
setHasChanges={setHasChanges}
|
||||
selectedLLMModel={selectedLLMModel}
|
||||
setSelectedLLMModel={setSelectedLLMModel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<NoSetupWarning
|
||||
showing={missingCredentials}
|
||||
onSetupClick={() => {
|
||||
@@ -128,18 +162,12 @@ export default function LLMSelectorModal({ workspaceSlug = null }) {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<ChatModelSelection
|
||||
provider={selectedLLMProvider}
|
||||
setHasChanges={setHasChanges}
|
||||
selectedLLMModel={selectedLLMModel}
|
||||
setSelectedLLMModel={setSelectedLLMModel}
|
||||
/>
|
||||
{hasChanges && (
|
||||
{hasChanges && !missingCredentials && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
className={`border-none text-xs px-4 py-1 font-semibold light:text-[#ffffff] rounded-lg bg-primary-button hover:bg-secondary hover:text-white h-[34px] whitespace-nowrap w-full`}
|
||||
className="border-none text-xs px-4 py-1.5 font-semibold rounded-lg bg-white text-zinc-900 hover:bg-zinc-200 light:bg-slate-800 light:text-white light:hover:bg-slate-700 h-8 w-full cursor-pointer transition-colors mt-auto"
|
||||
>
|
||||
{saving
|
||||
? t("chat_window.workspace_llm_manager.saving")
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useIsAgentSessionActive } from "@/utils/chat/agent";
|
||||
import AddPresetModal from "./AddPresetModal";
|
||||
import EditPresetModal from "./EditPresetModal";
|
||||
import { useModal } from "@/hooks/useModal";
|
||||
import System from "@/models/system";
|
||||
import { DotsThree, Plus } from "@phosphor-icons/react";
|
||||
import showToast from "@/utils/toast";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PublishEntityModal from "@/components/CommunityHub/PublishEntityModal";
|
||||
|
||||
export const CMD_REGEX = new RegExp(/[^a-zA-Z0-9_-]/g);
|
||||
export default function SlashPresets({ setShowing, sendCommand, promptRef }) {
|
||||
const { t } = useTranslation();
|
||||
const isActiveAgentSession = useIsAgentSessionActive();
|
||||
const {
|
||||
isOpen: isAddModalOpen,
|
||||
openModal: openAddModal,
|
||||
closeModal: closeAddModal,
|
||||
} = useModal();
|
||||
const {
|
||||
isOpen: isEditModalOpen,
|
||||
openModal: openEditModal,
|
||||
closeModal: closeEditModal,
|
||||
} = useModal();
|
||||
const {
|
||||
isOpen: isPublishModalOpen,
|
||||
openModal: openPublishModal,
|
||||
closeModal: closePublishModal,
|
||||
} = useModal();
|
||||
const [presets, setPresets] = useState([]);
|
||||
const [selectedPreset, setSelectedPreset] = useState(null);
|
||||
const [presetToPublish, setPresetToPublish] = useState(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
fetchPresets();
|
||||
}, []);
|
||||
|
||||
/*
|
||||
* @checklist-item
|
||||
* If the URL has the slash-commands param, open the add modal for the user
|
||||
* automatically when the component mounts.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
searchParams.get("action") === "open-new-slash-command-modal" &&
|
||||
!isAddModalOpen
|
||||
)
|
||||
openAddModal();
|
||||
}, []);
|
||||
|
||||
if (isActiveAgentSession) return null;
|
||||
|
||||
const fetchPresets = async () => {
|
||||
const presets = await System.getSlashCommandPresets();
|
||||
setPresets(presets);
|
||||
};
|
||||
|
||||
const handleSavePreset = async (preset) => {
|
||||
const { error } = await System.createSlashCommandPreset(preset);
|
||||
if (!!error) {
|
||||
showToast(error, "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
fetchPresets();
|
||||
closeAddModal();
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleEditPreset = (preset) => {
|
||||
setSelectedPreset(preset);
|
||||
openEditModal();
|
||||
};
|
||||
|
||||
const handleUpdatePreset = async (updatedPreset) => {
|
||||
const { error } = await System.updateSlashCommandPreset(
|
||||
updatedPreset.id,
|
||||
updatedPreset
|
||||
);
|
||||
|
||||
if (!!error) {
|
||||
showToast(error, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
fetchPresets();
|
||||
closeEditModalAndResetPreset();
|
||||
};
|
||||
|
||||
const handleDeletePreset = async (presetId) => {
|
||||
await System.deleteSlashCommandPreset(presetId);
|
||||
fetchPresets();
|
||||
closeEditModalAndResetPreset();
|
||||
};
|
||||
|
||||
const closeEditModalAndResetPreset = () => {
|
||||
closeEditModal();
|
||||
setSelectedPreset(null);
|
||||
};
|
||||
|
||||
const handlePublishPreset = (preset) => {
|
||||
setPresetToPublish({
|
||||
name: preset.command.slice(1),
|
||||
description: preset.description,
|
||||
command: preset.command,
|
||||
prompt: preset.prompt,
|
||||
});
|
||||
openPublishModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{presets.map((preset) => (
|
||||
<PresetItem
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
onUse={() => {
|
||||
setShowing(false);
|
||||
sendCommand({ text: `${preset.command} ` });
|
||||
promptRef?.current?.focus();
|
||||
}}
|
||||
onEdit={handleEditPreset}
|
||||
onPublish={handlePublishPreset}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="border-none w-full hover:cursor-pointer hover:bg-theme-action-menu-item-hover px-2 py-1 rounded-xl flex flex-col justify-start"
|
||||
>
|
||||
<div className="w-full flex-row flex pointer-events-none items-center gap-2">
|
||||
<Plus size={24} weight="fill" className="text-theme-text-primary" />
|
||||
<div className="text-theme-text-primary text-sm font-medium">
|
||||
{t("chat_window.add_new_preset")}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<AddPresetModal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={closeAddModal}
|
||||
onSave={handleSavePreset}
|
||||
/>
|
||||
{selectedPreset && (
|
||||
<EditPresetModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={closeEditModalAndResetPreset}
|
||||
onSave={handleUpdatePreset}
|
||||
onDelete={handleDeletePreset}
|
||||
preset={selectedPreset}
|
||||
/>
|
||||
)}
|
||||
<PublishEntityModal
|
||||
show={isPublishModalOpen}
|
||||
onClose={closePublishModal}
|
||||
entityType="slash-command"
|
||||
entity={presetToPublish}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetItem({ preset, onUse, onEdit, onPublish }) {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
const menuButtonRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (
|
||||
showMenu &&
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target) &&
|
||||
menuButtonRef.current &&
|
||||
!menuButtonRef.current.contains(event.target)
|
||||
) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [showMenu]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slash-command={preset.command}
|
||||
onClick={onUse}
|
||||
className="border-none w-full hover:cursor-pointer hover:bg-theme-action-menu-item-hover px-2 py-2 rounded-xl flex flex-row justify-start items-center relative"
|
||||
>
|
||||
<div className="flex-col text-left flex pointer-events-none flex-1 min-w-0">
|
||||
<div className="text-theme-text-primary text-sm font-bold truncate">
|
||||
{preset.command}
|
||||
</div>
|
||||
<div className="text-theme-text-secondary text-sm truncate">
|
||||
{preset.description}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowMenu(!showMenu);
|
||||
}}
|
||||
className="border-none text-theme-text-primary text-sm p-1 hover:cursor-pointer hover:bg-theme-action-menu-item-hover rounded-full ml-2 flex-shrink-0 z-20"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<DotsThree size={24} weight="bold" />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute right-0 top-10 bg-theme-bg-popup-menu rounded-lg z-50 min-w-[160px] shadow-lg border border-theme-modal-border flex flex-col"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="px-[10px] py-[6px] text-sm text-white hover:bg-theme-sidebar-item-hover rounded-t-lg cursor-pointer border-none w-full text-left whitespace-nowrap"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowMenu(false);
|
||||
onEdit(preset);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-[10px] py-[6px] text-sm text-white hover:bg-theme-sidebar-item-hover rounded-b-lg cursor-pointer border-none w-full text-left whitespace-nowrap"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowMenu(false);
|
||||
onPublish(preset);
|
||||
}}
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useIsAgentSessionActive } from "@/utils/chat/agent";
|
||||
|
||||
export default function EndAgentSession({ setShowing, sendCommand }) {
|
||||
const isActiveAgentSession = useIsAgentSessionActive();
|
||||
if (!isActiveAgentSession) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slash-command="/exit"
|
||||
onClick={() => {
|
||||
setShowing(false);
|
||||
sendCommand({ text: "/exit", autoSubmit: true });
|
||||
}}
|
||||
className="border-none w-full hover:cursor-pointer hover:bg-theme-action-menu-item-hover px-2 py-2 rounded-xl flex flex-col justify-start"
|
||||
>
|
||||
<div className="w-full flex-col text-left flex pointer-events-none">
|
||||
<div className="text-white text-sm font-bold">/exit</div>
|
||||
<div className="text-white text-opacity-60 text-sm">
|
||||
Halt the current agent session.
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export default function SlashCommandIcon(props) {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<rect
|
||||
x="1.02539"
|
||||
y="1.43799"
|
||||
width="17.252"
|
||||
height="17.252"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M6.70312 14.5408L12.5996 5.8056"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import SlashCommandIcon from "./icons/SlashCommandIcon";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import ResetCommand from "./reset";
|
||||
import EndAgentSession from "./endAgentSession";
|
||||
import SlashPresets from "./SlashPresets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSlashCommandKeyboardNavigation } from "@/hooks/useSlashCommandKeyboardNavigation";
|
||||
|
||||
export default function SlashCommandsButton({ showing, setShowSlashCommand }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
id="slash-cmd-btn"
|
||||
data-tooltip-id="tooltip-slash-cmd-btn"
|
||||
data-tooltip-content={t("chat_window.slash")}
|
||||
onClick={() => setShowSlashCommand(!showing)}
|
||||
className={`flex justify-center items-center cursor-pointer opacity-60 hover:opacity-100 light:opacity-100 light:hover:opacity-60 ${
|
||||
showing ? "!opacity-100" : ""
|
||||
}`}
|
||||
>
|
||||
<SlashCommandIcon
|
||||
color="var(--theme-sidebar-footer-icon-fill)"
|
||||
className="w-[18px] h-[18px] pointer-events-none"
|
||||
/>
|
||||
<Tooltip
|
||||
id="tooltip-slash-cmd-btn"
|
||||
place="top"
|
||||
delayShow={300}
|
||||
className="tooltip !text-xs z-99"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlashCommands({
|
||||
showing,
|
||||
setShowing,
|
||||
sendCommand,
|
||||
promptRef,
|
||||
centered = false,
|
||||
}) {
|
||||
const cmdRef = useRef(null);
|
||||
useSlashCommandKeyboardNavigation({ showing });
|
||||
|
||||
useEffect(() => {
|
||||
function listenForOutsideClick() {
|
||||
if (!showing || !cmdRef.current) return false;
|
||||
document.addEventListener("click", closeIfOutside);
|
||||
}
|
||||
listenForOutsideClick();
|
||||
}, [showing, cmdRef.current]);
|
||||
|
||||
const closeIfOutside = ({ target }) => {
|
||||
if (target.id === "slash-cmd-btn") return;
|
||||
const isOutside = !cmdRef?.current?.contains(target);
|
||||
if (!isOutside) return;
|
||||
setShowing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div hidden={!showing}>
|
||||
<div
|
||||
className={
|
||||
centered
|
||||
? "w-full flex justify-center md:justify-start absolute top-full mt-2 left-0 z-10 px-4 md:px-0 md:pl-[31px]"
|
||||
: "flex justify-center md:justify-start absolute bottom-[130px] md:bottom-[150px] left-0 right-0 z-10 max-w-[750px] mx-auto px-4 md:px-0 md:pl-[31px]"
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={cmdRef}
|
||||
className="w-[600px] bg-theme-action-menu-bg rounded-2xl flex shadow flex-col justify-start items-start gap-2.5 p-2 overflow-y-auto max-h-[200px] no-scroll"
|
||||
>
|
||||
<ResetCommand sendCommand={sendCommand} setShowing={setShowing} />
|
||||
<EndAgentSession sendCommand={sendCommand} setShowing={setShowing} />
|
||||
<SlashPresets
|
||||
sendCommand={sendCommand}
|
||||
setShowing={setShowing}
|
||||
promptRef={promptRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSlashCommands() {
|
||||
const [showSlashCommand, setShowSlashCommand] = useState(false);
|
||||
return { showSlashCommand, setShowSlashCommand };
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useIsAgentSessionActive } from "@/utils/chat/agent";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ResetCommand({ setShowing, sendCommand }) {
|
||||
const { t } = useTranslation();
|
||||
const isActiveAgentSession = useIsAgentSessionActive();
|
||||
if (isActiveAgentSession) return null; // cannot reset during active agent chat
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slash-command="/reset"
|
||||
onClick={() => {
|
||||
setShowing(false);
|
||||
sendCommand({ text: "/reset", autoSubmit: true });
|
||||
}}
|
||||
className="border-none w-full hover:cursor-pointer hover:bg-theme-action-menu-item-hover px-2 py-2 rounded-xl flex flex-col justify-start"
|
||||
>
|
||||
<div className="w-full flex-col text-left flex pointer-events-none">
|
||||
<div className="text-white text-sm font-bold">
|
||||
{t("chat_window.slash_reset")}
|
||||
</div>
|
||||
<div className="text-white text-opacity-60 text-sm">
|
||||
{t("chat_window.preset_reset_description")}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -125,15 +125,17 @@ export default function SpeechToText({ sendCommand }) {
|
||||
data-tooltip-content={`${t("chat_window.microphone")} (CTRL + M)`}
|
||||
aria-label={t("chat_window.microphone")}
|
||||
onClick={listening ? endSTTSession : startSTTSession}
|
||||
className={`border-none relative flex justify-center items-center opacity-60 hover:opacity-100 light:opacity-100 light:hover:opacity-60 cursor-pointer ${
|
||||
!!listening ? "!opacity-100" : ""
|
||||
className={`group border-none relative flex justify-center items-center cursor-pointer w-8 h-8 rounded-full hover:bg-zinc-700 light:hover:bg-slate-200 ${
|
||||
listening ? "bg-zinc-700 light:bg-slate-200" : ""
|
||||
}`}
|
||||
>
|
||||
<Microphone
|
||||
weight="regular"
|
||||
color="var(--theme-sidebar-footer-icon-fill)"
|
||||
className={`w-[20px] h-[20px] pointer-events-none text-theme-text-primary ${
|
||||
listening ? "animate-pulse-glow" : ""
|
||||
size={18}
|
||||
className={`pointer-events-none text-zinc-300 light:text-slate-600 group-hover:text-white light:group-hover:text-slate-600 shrink-0 ${
|
||||
listening
|
||||
? "animate-pulse-glow !text-white light:!text-slate-800"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
<Tooltip
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ABORT_STREAM_EVENT } from "@/utils/chat";
|
||||
import { Stop } from "@phosphor-icons/react";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function StopGenerationButton() {
|
||||
const { t } = useTranslation();
|
||||
function emitHaltEvent() {
|
||||
window.dispatchEvent(new CustomEvent(ABORT_STREAM_EVENT));
|
||||
}
|
||||
@@ -13,14 +14,11 @@ export default function StopGenerationButton() {
|
||||
type="button"
|
||||
onClick={emitHaltEvent}
|
||||
data-tooltip-id="stop-generation-button"
|
||||
data-tooltip-content="Stop generating response"
|
||||
className="border-none inline-flex justify-center items-center rounded-full cursor-pointer w-[20px] h-[20px] light:bg-slate-800 bg-white hover:opacity-80 transition-opacity"
|
||||
data-tooltip-content={t("chat_window.stop_generating")}
|
||||
className="border-none inline-flex justify-center items-center rounded-full cursor-pointer w-8 h-8 bg-white light:bg-slate-800 hover:opacity-80 transition-opacity"
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<Stop
|
||||
className="w-[12px] h-[12px] light:text-white text-black"
|
||||
weight="fill"
|
||||
/>
|
||||
<div className="w-3.5 h-3.5 rounded-[4px] bg-zinc-800 light:bg-white" />
|
||||
</button>
|
||||
<Tooltip
|
||||
id="stop-generation-button"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Toggle from "@/components/lib/Toggle";
|
||||
|
||||
export default function SkillRow({
|
||||
name,
|
||||
enabled,
|
||||
onToggle,
|
||||
highlighted = false,
|
||||
disabled = false,
|
||||
}) {
|
||||
let classNames = "flex items-center justify-between px-2 py-1 rounded";
|
||||
if (highlighted) classNames += " bg-zinc-700/50 light:bg-slate-100";
|
||||
else classNames += " hover:bg-zinc-700/50 light:hover:bg-slate-100";
|
||||
|
||||
if (disabled) classNames += " opacity-60 cursor-not-allowed";
|
||||
else classNames += " cursor-pointer";
|
||||
return (
|
||||
<div
|
||||
className={classNames}
|
||||
data-tooltip-id={disabled ? "agent-skill-disabled-tooltip" : undefined}
|
||||
>
|
||||
<span className="text-xs text-white light:text-slate-900">{name}</span>
|
||||
<Toggle
|
||||
size="sm"
|
||||
enabled={enabled}
|
||||
onChange={onToggle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import paths from "@/utils/paths";
|
||||
import Admin from "@/models/admin";
|
||||
import System from "@/models/system";
|
||||
import AgentPlugins from "@/models/experimental/agentPlugins";
|
||||
import AgentFlows from "@/models/agentFlows";
|
||||
import {
|
||||
getDefaultSkills,
|
||||
getConfigurableSkills,
|
||||
} from "@/pages/Admin/Agents/skills";
|
||||
import useToolsMenuItems from "../../useToolsMenuItems";
|
||||
import SkillRow from "./SkillRow";
|
||||
import { Wrench } from "@phosphor-icons/react";
|
||||
import { useIsAgentSessionActive } from "@/utils/chat/agent";
|
||||
|
||||
export default function AgentSkillsTab({
|
||||
highlightedIndex = -1,
|
||||
registerItemCount,
|
||||
workspace,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { showAgentCommand = true } = workspace ?? {};
|
||||
const agentSessionActive = useIsAgentSessionActive();
|
||||
const defaultSkills = getDefaultSkills(t);
|
||||
const [fileSystemAgentAvailable, setFileSystemAgentAvailable] =
|
||||
useState(false);
|
||||
const configurableSkills = getConfigurableSkills(t, {
|
||||
fileSystemAgentAvailable,
|
||||
});
|
||||
const [disabledDefaults, setDisabledDefaults] = useState([]);
|
||||
const [enabledConfigurable, setEnabledConfigurable] = useState([]);
|
||||
const [importedSkills, setImportedSkills] = useState([]);
|
||||
const [flows, setFlows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const showAgentCmdActivationAlert = showAgentCommand && !agentSessionActive;
|
||||
|
||||
useEffect(() => {
|
||||
fetchSkillSettings();
|
||||
}, []);
|
||||
|
||||
async function fetchSkillSettings() {
|
||||
try {
|
||||
const [prefs, flowsRes, fsAgentAvailable] = await Promise.all([
|
||||
Admin.systemPreferencesByFields([
|
||||
"disabled_agent_skills",
|
||||
"default_agent_skills",
|
||||
"imported_agent_skills",
|
||||
]),
|
||||
AgentFlows.listFlows(),
|
||||
System.isFileSystemAgentAvailable(),
|
||||
]);
|
||||
|
||||
if (prefs?.settings) {
|
||||
setDisabledDefaults(prefs.settings.disabled_agent_skills ?? []);
|
||||
setEnabledConfigurable(prefs.settings.default_agent_skills ?? []);
|
||||
setImportedSkills(prefs.settings.imported_agent_skills ?? []);
|
||||
}
|
||||
if (flowsRes?.flows) setFlows(flowsRes.flows);
|
||||
setFileSystemAgentAvailable(fsAgentAvailable);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleItem(arr, item) {
|
||||
return arr.includes(item) ? arr.filter((s) => s !== item) : [...arr, item];
|
||||
}
|
||||
|
||||
function isSkillEnabled(key) {
|
||||
return key in defaultSkills
|
||||
? !disabledDefaults.includes(key)
|
||||
: enabledConfigurable.includes(key);
|
||||
}
|
||||
|
||||
async function toggleSkill(key) {
|
||||
if (key in defaultSkills) {
|
||||
const updated = toggleItem(disabledDefaults, key);
|
||||
setDisabledDefaults(updated);
|
||||
await Admin.updateSystemPreferences({
|
||||
disabled_agent_skills: updated.join(","),
|
||||
default_agent_skills: enabledConfigurable.join(","),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = toggleItem(enabledConfigurable, key);
|
||||
setEnabledConfigurable(updated);
|
||||
await Admin.updateSystemPreferences({
|
||||
disabled_agent_skills: disabledDefaults.join(","),
|
||||
default_agent_skills: updated.join(","),
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleImportedSkill(skill) {
|
||||
const newActive = !skill.active;
|
||||
setImportedSkills((prev) =>
|
||||
prev.map((s) =>
|
||||
s.hubId === skill.hubId ? { ...s, active: newActive } : s
|
||||
)
|
||||
);
|
||||
await AgentPlugins.toggleFeature(skill.hubId, newActive);
|
||||
}
|
||||
|
||||
async function toggleFlow(flow) {
|
||||
const newActive = !flow.active;
|
||||
setFlows((prev) =>
|
||||
prev.map((f) => (f.uuid === flow.uuid ? { ...f, active: newActive } : f))
|
||||
);
|
||||
await AgentFlows.toggleFlow(flow.uuid, newActive);
|
||||
}
|
||||
|
||||
// Build list of all skill items for rendering/keyboard navigation
|
||||
const items = useMemo(() => {
|
||||
const list = [];
|
||||
for (const [key, { title }] of Object.entries({
|
||||
...defaultSkills,
|
||||
...configurableSkills,
|
||||
})) {
|
||||
list.push({
|
||||
id: key,
|
||||
name: title,
|
||||
enabled: isSkillEnabled(key),
|
||||
onToggle: () => toggleSkill(key),
|
||||
});
|
||||
}
|
||||
for (const skill of importedSkills) {
|
||||
list.push({
|
||||
id: skill.hubId,
|
||||
name: skill.name,
|
||||
enabled: skill.active,
|
||||
onToggle: () => toggleImportedSkill(skill),
|
||||
});
|
||||
}
|
||||
for (const flow of flows) {
|
||||
list.push({
|
||||
id: flow.uuid,
|
||||
name: flow.name,
|
||||
enabled: flow.active,
|
||||
onToggle: () => toggleFlow(flow),
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [disabledDefaults, enabledConfigurable, importedSkills, flows]);
|
||||
|
||||
useToolsMenuItems({
|
||||
items,
|
||||
highlightedIndex,
|
||||
onSelect: agentSessionActive ? () => {} : (item) => item.onToggle(),
|
||||
registerItemCount,
|
||||
});
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAgentCmdActivationAlert && (
|
||||
<p className="text-xs text-theme-text-secondary text-center py-1">
|
||||
{t("chat_window.use_agent_session_to_use_tools")}
|
||||
</p>
|
||||
)}
|
||||
{items.map((item, index) => (
|
||||
<SkillRow
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
enabled={item.enabled}
|
||||
onToggle={item.onToggle}
|
||||
highlighted={highlightedIndex === index}
|
||||
disabled={agentSessionActive}
|
||||
/>
|
||||
))}
|
||||
<Link to={paths.settings.agentSkills()}>
|
||||
<button className="flex items-center gap-1.5 px-2 h-6 rounded cursor-pointer hover:bg-zinc-700/50 light:hover:bg-slate-100 text-theme-text-primary">
|
||||
<Wrench size={12} className="text-theme-text-primary" />
|
||||
<span className="text-xs text-theme-text-primary">
|
||||
{t("chat_window.manage_agent_skills")}
|
||||
</span>
|
||||
</button>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { DotsThree } from "@phosphor-icons/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function SlashCommandRow({
|
||||
command,
|
||||
description,
|
||||
onClick,
|
||||
onEdit,
|
||||
onPublish,
|
||||
showMenu = false,
|
||||
highlighted = false,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
|
||||
const menuRef = useRef(null);
|
||||
const menuBtnRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
function handleClickOutside(e) {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(e.target) &&
|
||||
menuBtnRef.current &&
|
||||
!menuBtnRef.current.contains(e.target)
|
||||
) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [menuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpen && menuBtnRef.current) {
|
||||
const rect = menuBtnRef.current.getBoundingClientRect();
|
||||
setMenuPosition({
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.right + window.scrollX - 120,
|
||||
});
|
||||
}
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`flex items-center justify-between px-2 py-1 rounded cursor-pointer group relative ${
|
||||
highlighted
|
||||
? "bg-zinc-700/50 light:bg-slate-100"
|
||||
: "hover:bg-zinc-700/50 light:hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-1.5 items-center text-xs min-w-0 flex-1">
|
||||
<span className="text-white light:text-slate-900 shrink-0">
|
||||
{command}
|
||||
</span>
|
||||
<span className="text-zinc-400 light:text-slate-500 italic truncate">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showMenu && (
|
||||
<div className="relative shrink-0 ml-1">
|
||||
<button
|
||||
ref={menuBtnRef}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(!menuOpen);
|
||||
}}
|
||||
className="border-none cursor-pointer text-zinc-400 light:text-slate-500 p-0.5 hover:text-white light:hover:text-slate-900 rounded opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<DotsThree size={16} weight="bold" />
|
||||
</button>
|
||||
|
||||
{menuOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: menuPosition.top,
|
||||
left: menuPosition.left,
|
||||
}}
|
||||
className="z-[9999] bg-zinc-800 light:bg-white border border-zinc-700 light:border-slate-300 rounded-lg shadow-lg min-w-[120px] flex flex-col overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="border-none px-3 py-1.5 text-xs text-white light:text-slate-900 hover:bg-zinc-700 light:hover:bg-slate-100 cursor-pointer text-left"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
onEdit?.();
|
||||
}}
|
||||
>
|
||||
{t("chat_window.edit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="border-none px-3 py-1.5 text-xs text-white light:text-slate-900 hover:bg-zinc-700 light:hover:bg-slate-100 cursor-pointer text-left"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
onPublish?.();
|
||||
}}
|
||||
>
|
||||
{t("chat_window.publish")}
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import ModalWrapper from "@/components/ModalWrapper";
|
||||
import { CMD_REGEX } from ".";
|
||||
import { CMD_REGEX } from "./constants";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function AddPresetModal({ isOpen, onClose, onSave }) {
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import ModalWrapper from "@/components/ModalWrapper";
|
||||
import { CMD_REGEX } from ".";
|
||||
import { CMD_REGEX } from "./constants";
|
||||
|
||||
export default function EditPresetModal({
|
||||
isOpen,
|
||||
@@ -0,0 +1 @@
|
||||
export const CMD_REGEX = /[^a-zA-Z0-9_-]/g;
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import System from "@/models/system";
|
||||
import { useModal } from "@/hooks/useModal";
|
||||
import AddPresetModal from "./SlashPresets/AddPresetModal";
|
||||
import EditPresetModal from "./SlashPresets/EditPresetModal";
|
||||
import PublishEntityModal from "@/components/CommunityHub/PublishEntityModal";
|
||||
import showToast from "@/utils/toast";
|
||||
import { useIsAgentSessionActive } from "@/utils/chat/agent";
|
||||
import { PROMPT_INPUT_EVENT } from "@/components/WorkspaceChat/ChatContainer/PromptInput";
|
||||
import useToolsMenuItems from "../../useToolsMenuItems";
|
||||
import SlashCommandRow from "./SlashCommandRow";
|
||||
|
||||
export default function SlashCommandsTab({
|
||||
sendCommand,
|
||||
setShowing,
|
||||
promptRef,
|
||||
highlightedIndex = -1,
|
||||
registerItemCount,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isActiveAgentSession = useIsAgentSessionActive();
|
||||
const {
|
||||
isOpen: isAddModalOpen,
|
||||
openModal: openAddModal,
|
||||
closeModal: closeAddModal,
|
||||
} = useModal();
|
||||
const {
|
||||
isOpen: isEditModalOpen,
|
||||
openModal: openEditModal,
|
||||
closeModal: closeEditModal,
|
||||
} = useModal();
|
||||
const {
|
||||
isOpen: isPublishModalOpen,
|
||||
openModal: openPublishModal,
|
||||
closeModal: closePublishModal,
|
||||
} = useModal();
|
||||
const [presets, setPresets] = useState([]);
|
||||
const [selectedPreset, setSelectedPreset] = useState(null);
|
||||
const [presetToPublish, setPresetToPublish] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPresets();
|
||||
}, []);
|
||||
|
||||
const fetchPresets = async () => {
|
||||
const presets = await System.getSlashCommandPresets();
|
||||
setPresets(presets);
|
||||
};
|
||||
|
||||
// Build the list of selectable items for keyboard navigation and rendering
|
||||
// Command names must stay as static English strings since the backend
|
||||
// matches against exact "/reset" and "/exit" commands.
|
||||
const items = useMemo(() => {
|
||||
const builtIn = isActiveAgentSession
|
||||
? {
|
||||
command: "/exit",
|
||||
description: t("chat_window.preset_exit_description"),
|
||||
autoSubmit: true,
|
||||
}
|
||||
: {
|
||||
command: "/reset",
|
||||
description: t("chat_window.preset_reset_description"),
|
||||
autoSubmit: true,
|
||||
};
|
||||
|
||||
return [
|
||||
builtIn,
|
||||
...presets.map((preset) => ({
|
||||
command: preset.command,
|
||||
description: preset.description,
|
||||
autoSubmit: false,
|
||||
preset,
|
||||
})),
|
||||
];
|
||||
}, [isActiveAgentSession, presets]);
|
||||
|
||||
const handleUseCommand = useCallback(
|
||||
(command, autoSubmit = false) => {
|
||||
setShowing(false);
|
||||
|
||||
// Auto-submit commands (/reset, /exit) fire immediately
|
||||
if (autoSubmit) {
|
||||
sendCommand({ text: command, autoSubmit: true });
|
||||
promptRef?.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert the command at the cursor, replacing a trailing "/" if present
|
||||
const textarea = promptRef?.current;
|
||||
if (!textarea) return;
|
||||
const cursor = textarea.selectionStart;
|
||||
const value = textarea.value;
|
||||
const charBefore = cursor > 0 ? value[cursor - 1] : "";
|
||||
const insertStart = charBefore === "/" ? cursor - 1 : cursor;
|
||||
const newValue =
|
||||
value.slice(0, insertStart) + command + value.slice(cursor);
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(PROMPT_INPUT_EVENT, {
|
||||
detail: { messageContent: newValue },
|
||||
})
|
||||
);
|
||||
textarea.focus();
|
||||
const newCursor = insertStart + command.length;
|
||||
setTimeout(() => textarea.setSelectionRange(newCursor, newCursor), 0);
|
||||
},
|
||||
[sendCommand, setShowing, promptRef]
|
||||
);
|
||||
|
||||
useToolsMenuItems({
|
||||
items,
|
||||
highlightedIndex,
|
||||
onSelect: (item) => {
|
||||
const text = item.preset ? `${item.command} ` : item.command;
|
||||
handleUseCommand(text, item.autoSubmit);
|
||||
},
|
||||
registerItemCount,
|
||||
});
|
||||
|
||||
const handleSavePreset = async (preset) => {
|
||||
const { error } = await System.createSlashCommandPreset(preset);
|
||||
if (error) {
|
||||
showToast(error, "error");
|
||||
return false;
|
||||
}
|
||||
fetchPresets();
|
||||
closeAddModal();
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleEditPreset = (preset) => {
|
||||
setSelectedPreset(preset);
|
||||
openEditModal();
|
||||
};
|
||||
|
||||
const handleUpdatePreset = async (updatedPreset) => {
|
||||
const { error } = await System.updateSlashCommandPreset(
|
||||
updatedPreset.id,
|
||||
updatedPreset
|
||||
);
|
||||
if (error) {
|
||||
showToast(error, "error");
|
||||
return;
|
||||
}
|
||||
fetchPresets();
|
||||
closeEditModal();
|
||||
setSelectedPreset(null);
|
||||
};
|
||||
|
||||
const handleDeletePreset = async (presetId) => {
|
||||
await System.deleteSlashCommandPreset(presetId);
|
||||
fetchPresets();
|
||||
closeEditModal();
|
||||
setSelectedPreset(null);
|
||||
};
|
||||
|
||||
const handlePublishPreset = (preset) => {
|
||||
setPresetToPublish({
|
||||
name: preset.command.slice(1),
|
||||
description: preset.description,
|
||||
command: preset.command,
|
||||
prompt: preset.prompt,
|
||||
});
|
||||
openPublishModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item, index) => (
|
||||
<SlashCommandRow
|
||||
key={item.preset?.id ?? item.command}
|
||||
command={item.command}
|
||||
description={item.description}
|
||||
onClick={() =>
|
||||
handleUseCommand(
|
||||
item.preset ? `${item.command} ` : item.command,
|
||||
item.autoSubmit
|
||||
)
|
||||
}
|
||||
onEdit={item.preset ? () => handleEditPreset(item.preset) : undefined}
|
||||
onPublish={
|
||||
item.preset ? () => handlePublishPreset(item.preset) : undefined
|
||||
}
|
||||
showMenu={!!item.preset}
|
||||
highlighted={highlightedIndex === index}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add new */}
|
||||
{!isActiveAgentSession && (
|
||||
<div
|
||||
onClick={openAddModal}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded cursor-pointer hover:bg-zinc-700/50 light:hover:bg-slate-100"
|
||||
>
|
||||
<Plus
|
||||
size={12}
|
||||
weight="bold"
|
||||
className="text-white light:text-slate-900"
|
||||
/>
|
||||
<span className="text-xs text-white light:text-slate-900">
|
||||
{t("chat_window.add_new")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<AddPresetModal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={closeAddModal}
|
||||
onSave={handleSavePreset}
|
||||
/>
|
||||
{selectedPreset && (
|
||||
<EditPresetModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => {
|
||||
closeEditModal();
|
||||
setSelectedPreset(null);
|
||||
}}
|
||||
onSave={handleUpdatePreset}
|
||||
onDelete={handleDeletePreset}
|
||||
preset={selectedPreset}
|
||||
/>
|
||||
)}
|
||||
<PublishEntityModal
|
||||
show={isPublishModalOpen}
|
||||
onClose={closePublishModal}
|
||||
entityType="slash-command"
|
||||
entity={presetToPublish}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import AgentSkillsTab from "./Tabs/AgentSkills";
|
||||
import SlashCommandsTab from "./Tabs/SlashCommands";
|
||||
|
||||
export const TOOLS_MENU_KEYBOARD_EVENT = "tools-menu-keyboard";
|
||||
function getTabs(t, user) {
|
||||
const tabs = [
|
||||
{
|
||||
key: "slash-commands",
|
||||
label: t("chat_window.slash_commands"),
|
||||
component: SlashCommandsTab,
|
||||
},
|
||||
];
|
||||
|
||||
// Only show agent skills tab for admins or when multiuser mode is off
|
||||
const canSeeAgentSkills =
|
||||
!user?.hasOwnProperty("role") || user.role === "admin";
|
||||
if (canSeeAgentSkills) {
|
||||
tabs.push({
|
||||
key: "agent-skills",
|
||||
label: t("chat_window.agent_skills"),
|
||||
component: AgentSkillsTab,
|
||||
});
|
||||
}
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Workspace} props.workspace - the workspace object
|
||||
* @param {boolean} props.showing
|
||||
* @param {function} props.setShowing
|
||||
* @param {function} props.sendCommand
|
||||
* @param {object} props.promptRef
|
||||
* @param {boolean} [props.centered] - when true, popup opens below the input
|
||||
*/
|
||||
export default function ToolsMenu({
|
||||
workspace,
|
||||
showing,
|
||||
setShowing,
|
||||
sendCommand,
|
||||
promptRef,
|
||||
centered = false,
|
||||
highlightedIndexRef,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useUser();
|
||||
const TABS = useMemo(() => getTabs(t, user), [t, user]);
|
||||
const [activeTab, setActiveTab] = useState(TABS[0].key);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const itemCountRef = useRef(0);
|
||||
|
||||
// Always open to the slash commands
|
||||
useEffect(() => {
|
||||
if (showing) setActiveTab(TABS[0].key);
|
||||
}, [showing]);
|
||||
|
||||
// Reset highlight when switching tabs or closing
|
||||
useEffect(() => {
|
||||
setHighlightedIndex(-1);
|
||||
}, [activeTab, showing]);
|
||||
|
||||
// Keep the parent ref in sync so PromptInput can check it on Enter
|
||||
useEffect(() => {
|
||||
if (highlightedIndexRef) highlightedIndexRef.current = highlightedIndex;
|
||||
}, [highlightedIndex]);
|
||||
|
||||
const registerItemCount = useCallback((count) => {
|
||||
itemCountRef.current = count;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showing) return;
|
||||
|
||||
function handleKeyboard(e) {
|
||||
const { key } = e.detail;
|
||||
|
||||
if (key === "ArrowLeft" || key === "ArrowRight") {
|
||||
const currentIdx = TABS.findIndex((tab) => tab.key === activeTab);
|
||||
const nextIdx =
|
||||
key === "ArrowLeft"
|
||||
? (currentIdx - 1 + TABS.length) % TABS.length
|
||||
: (currentIdx + 1) % TABS.length;
|
||||
setActiveTab(TABS[nextIdx].key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowUp" || key === "ArrowDown") {
|
||||
const count = itemCountRef.current;
|
||||
if (count === 0) return;
|
||||
setHighlightedIndex((prev) => {
|
||||
if (key === "ArrowDown") {
|
||||
return prev < count - 1 ? prev + 1 : 0;
|
||||
}
|
||||
return prev > 0 ? prev - 1 : count - 1;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter is handled by the tab components via highlightedIndex
|
||||
}
|
||||
|
||||
window.addEventListener(TOOLS_MENU_KEYBOARD_EVENT, handleKeyboard);
|
||||
return () =>
|
||||
window.removeEventListener(TOOLS_MENU_KEYBOARD_EVENT, handleKeyboard);
|
||||
}, [showing, activeTab]);
|
||||
|
||||
if (!showing) return null;
|
||||
|
||||
const { component: ActiveTab } = TABS.find((tab) => tab.key === activeTab);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setShowing(false)}
|
||||
/>
|
||||
<div
|
||||
onMouseDown={(e) => {
|
||||
// Prevents prompt textarea from losing focus when clicking inside the menu.
|
||||
// Skip for portaled modals so their inputs can still receive focus.
|
||||
if (e.currentTarget.contains(e.target)) e.preventDefault();
|
||||
}}
|
||||
className={`absolute left-2 right-2 md:left-14 md:right-auto md:w-[400px] z-50 bg-zinc-800 light:bg-white border border-zinc-700 light:border-slate-300 rounded-lg p-3 flex flex-col gap-2.5 shadow-lg overflow-hidden ${
|
||||
centered
|
||||
? "top-full mt-2 max-h-[min(360px,calc(100dvh-25rem))]"
|
||||
: "bottom-full mb-2 max-h-[min(360px,calc(100dvh-11rem))]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex shrink-0 gap-2.5 items-center">
|
||||
{TABS.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.key}
|
||||
active={activeTab === tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</TabButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 overflow-y-auto no-scroll flex-1 min-h-0">
|
||||
<ActiveTab
|
||||
sendCommand={sendCommand}
|
||||
setShowing={setShowing}
|
||||
promptRef={promptRef}
|
||||
highlightedIndex={highlightedIndex}
|
||||
registerItemCount={registerItemCount}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`border-none cursor-pointer hover:bg-zinc-700/50 light:hover:bg-slate-100 px-1.5 py-0.5 rounded text-[10px] font-medium text-center whitespace-nowrap ${
|
||||
active
|
||||
? "bg-zinc-700 text-white light:bg-slate-200 light:text-slate-800"
|
||||
: "text-zinc-400 light:text-slate-800"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from "react";
|
||||
import { TOOLS_MENU_KEYBOARD_EVENT } from "./";
|
||||
|
||||
/**
|
||||
* Shared hook for ToolsMenu tabs that registers the item count
|
||||
* for Up/Down navigation and handles Enter to select the highlighted item.
|
||||
* @param {Array} items - the list of items rendered in the tab
|
||||
* @param {number} highlightedIndex - currently highlighted index from parent
|
||||
* @param {function} onSelect - called with the highlighted item on Enter
|
||||
* @param {function} registerItemCount - callback to register total item count with parent
|
||||
*/
|
||||
export default function useToolsMenuItems({
|
||||
items,
|
||||
highlightedIndex,
|
||||
onSelect,
|
||||
registerItemCount,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
registerItemCount?.(items.length);
|
||||
}, [items.length, registerItemCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightedIndex < 0 || highlightedIndex >= items.length) return;
|
||||
function handleEnter(e) {
|
||||
if (e.detail.key !== "Enter") return;
|
||||
onSelect(items[highlightedIndex]);
|
||||
}
|
||||
window.addEventListener(TOOLS_MENU_KEYBOARD_EVENT, handleEnter);
|
||||
return () =>
|
||||
window.removeEventListener(TOOLS_MENU_KEYBOARD_EVENT, handleEnter);
|
||||
}, [highlightedIndex, items, onSelect]);
|
||||
}
|
||||
@@ -1,17 +1,7 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import SlashCommandsButton, {
|
||||
SlashCommands,
|
||||
useSlashCommands,
|
||||
} from "./SlashCommands";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import debounce from "lodash.debounce";
|
||||
import { ArrowUp } from "@phosphor-icons/react";
|
||||
import { ArrowUp, At } from "@phosphor-icons/react";
|
||||
import StopGenerationButton from "./StopGenerationButton";
|
||||
import AvailableAgentsButton, {
|
||||
AvailableAgents,
|
||||
useAvailableAgents,
|
||||
} from "./AgentMenu";
|
||||
import TextSizeButton from "./TextSizeMenu";
|
||||
import LLMSelectorAction from "./LLMSelector/action";
|
||||
import SpeechToText from "./SpeechToText";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import AttachmentManager from "./Attachments";
|
||||
@@ -25,12 +15,16 @@ import useTextSize from "@/hooks/useTextSize";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Appearance from "@/models/appearance";
|
||||
import usePromptInputStorage from "@/hooks/usePromptInputStorage";
|
||||
import ToolsMenu, { TOOLS_MENU_KEYBOARD_EVENT } from "./ToolsMenu";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useIsAgentSessionActive } from "@/utils/chat/agent";
|
||||
|
||||
export const PROMPT_INPUT_ID = "primary-prompt-input";
|
||||
export const PROMPT_INPUT_EVENT = "set_prompt_input";
|
||||
const MAX_EDIT_STACK_SIZE = 100;
|
||||
|
||||
/**
|
||||
* @param {Workspace} props.workspace - workspace object
|
||||
* @param {function} props.submit - form submit handler
|
||||
* @param {boolean} props.isStreaming - disables input while streaming response
|
||||
* @param {function} props.sendCommand - handler for slash commands and agent mentions
|
||||
@@ -40,6 +34,7 @@ const MAX_EDIT_STACK_SIZE = 100;
|
||||
* @param {string} [props.threadSlug] - thread slug for home page context
|
||||
*/
|
||||
export default function PromptInput({
|
||||
workspace = {},
|
||||
submit,
|
||||
isStreaming,
|
||||
sendCommand,
|
||||
@@ -49,16 +44,20 @@ export default function PromptInput({
|
||||
threadSlug = null,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { showAgentCommand = true } = workspace ?? {};
|
||||
const { isDisabled } = useIsDisabled();
|
||||
const agentSessionActive = useIsAgentSessionActive();
|
||||
const [promptInput, setPromptInput] = useState("");
|
||||
const { showAgents, setShowAgents } = useAvailableAgents();
|
||||
const { showSlashCommand, setShowSlashCommand } = useSlashCommands();
|
||||
const [showTools, setShowTools] = useState(false);
|
||||
const autoOpenedToolsRef = useRef(false);
|
||||
const toolsHighlightRef = useRef(-1);
|
||||
const formRef = useRef(null);
|
||||
const textareaRef = useRef(null);
|
||||
const [_, setFocused] = useState(false);
|
||||
const undoStack = useRef([]);
|
||||
const redoStack = useRef([]);
|
||||
const { textSizeClass } = useTextSize();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Synchronizes prompt input value with localStorage, scoped to the current thread.
|
||||
usePromptInputStorage({
|
||||
@@ -66,6 +65,18 @@ export default function PromptInput({
|
||||
setPromptInput,
|
||||
});
|
||||
|
||||
/*
|
||||
* @checklist-item
|
||||
* If the URL has the agent param, open the agent menu for the user
|
||||
* automatically when the component mounts.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (searchParams.get("action") === "set-agent-chat") {
|
||||
sendCommand({ text: "@agent " });
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [textareaRef.current]);
|
||||
|
||||
/**
|
||||
* To prevent too many re-renders we remotely listen for updates from the parent
|
||||
* via an event cycle. Otherwise, using message as a prop leads to a re-render every
|
||||
@@ -75,6 +86,8 @@ export default function PromptInput({
|
||||
function handlePromptUpdate(e) {
|
||||
const { messageContent, writeMode = "replace" } = e?.detail ?? {};
|
||||
if (writeMode === "append") setPromptInput((prev) => prev + messageContent);
|
||||
else if (writeMode === "prepend")
|
||||
setPromptInput((prev) => messageContent + " " + prev);
|
||||
else setPromptInput(messageContent ?? "");
|
||||
}
|
||||
|
||||
@@ -106,7 +119,10 @@ export default function PromptInput({
|
||||
const debouncedSaveState = debounce(saveCurrentState, 250);
|
||||
|
||||
function handleSubmit(e) {
|
||||
// Ignore submits from portaled modals (slash command preset forms)
|
||||
if (e.target !== e.currentTarget) return;
|
||||
setFocused(false);
|
||||
setShowTools(false);
|
||||
submit(e);
|
||||
}
|
||||
|
||||
@@ -115,31 +131,63 @@ export default function PromptInput({
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
|
||||
function checkForSlash(e) {
|
||||
const input = e.target.value;
|
||||
if (input === "/") setShowSlashCommand(true);
|
||||
if (showSlashCommand) setShowSlashCommand(false);
|
||||
return;
|
||||
}
|
||||
const watchForSlash = debounce(checkForSlash, 300);
|
||||
|
||||
function checkForAt(e) {
|
||||
const input = e.target.value;
|
||||
if (input === "@") return setShowAgents(true);
|
||||
if (showAgents) return setShowAgents(false);
|
||||
}
|
||||
const watchForAt = debounce(checkForAt, 300);
|
||||
|
||||
/**
|
||||
* Capture enter key press to handle submission, redo, or undo
|
||||
* via keyboard shortcuts
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
function captureEnterOrUndo(event) {
|
||||
// Forward keyboard events to the ToolsMenu when open
|
||||
if (showTools) {
|
||||
if (
|
||||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)
|
||||
) {
|
||||
event.preventDefault();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(TOOLS_MENU_KEYBOARD_EVENT, {
|
||||
detail: { key: event.key },
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
// When an item is highlighted via arrow keys, Enter selects it.
|
||||
// Otherwise, Enter falls through to submit the form normally.
|
||||
if (event.key === "Enter" && toolsHighlightRef.current >= 0) {
|
||||
event.preventDefault();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(TOOLS_MENU_KEYBOARD_EVENT, {
|
||||
detail: { key: "Enter" },
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setShowTools(false);
|
||||
textareaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// "/" toggles the Tools menu only when the input is empty
|
||||
if (
|
||||
event.key === "/" &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
promptInput.trim() === ""
|
||||
) {
|
||||
setShowTools((prev) => {
|
||||
autoOpenedToolsRef.current = !prev;
|
||||
return !prev;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Is simple enter key press w/o shift key
|
||||
if (event.keyCode === 13 && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (isStreaming || isDisabled) return; // Prevent submission if streaming or disabled
|
||||
setShowTools(false);
|
||||
return submit(event);
|
||||
}
|
||||
|
||||
@@ -252,10 +300,15 @@ export default function PromptInput({
|
||||
|
||||
function handleChange(e) {
|
||||
debouncedSaveState(-1);
|
||||
watchForSlash(e);
|
||||
watchForAt(e);
|
||||
adjustTextArea(e);
|
||||
setPromptInput(e.target.value);
|
||||
const value = e.target.value;
|
||||
setPromptInput(value);
|
||||
|
||||
// Auto-dismiss the tools menu when the "/" that opened it is modified
|
||||
if (autoOpenedToolsRef.current && showTools && value !== "/") {
|
||||
setShowTools(false);
|
||||
autoOpenedToolsRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -263,23 +316,9 @@ export default function PromptInput({
|
||||
className={
|
||||
centered
|
||||
? "w-full relative flex justify-center items-center"
|
||||
: "w-full fixed md:absolute bottom-0 left-0 z-10 md:z-0 flex justify-center items-center pwa:pb-5"
|
||||
: "w-full fixed md:absolute bottom-0 left-0 z-10 flex justify-center items-center pwa:pb-5"
|
||||
}
|
||||
>
|
||||
<SlashCommands
|
||||
showing={showSlashCommand}
|
||||
setShowing={setShowSlashCommand}
|
||||
sendCommand={sendCommand}
|
||||
promptRef={textareaRef}
|
||||
centered={centered}
|
||||
/>
|
||||
<AvailableAgents
|
||||
showing={showAgents}
|
||||
setShowing={setShowAgents}
|
||||
sendCommand={sendCommand}
|
||||
promptRef={textareaRef}
|
||||
centered={centered}
|
||||
/>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={
|
||||
@@ -291,80 +330,73 @@ export default function PromptInput({
|
||||
<div
|
||||
className={`flex items-center rounded-lg md:w-full ${centered ? "mb-0" : "mb-4"}`}
|
||||
>
|
||||
<div className="w-[95vw] md:w-[750px] bg-theme-bg-chat-input light:bg-white light:border-solid light:border-[1px] light:border-theme-chat-input-border shadow-sm rounded-[20px] pwa:rounded-3xl flex flex-col px-2 overflow-hidden">
|
||||
<AttachmentManager attachments={attachments} />
|
||||
<div className="flex items-center mx-[7px]">
|
||||
<textarea
|
||||
id={PROMPT_INPUT_ID}
|
||||
ref={textareaRef}
|
||||
onChange={handleChange}
|
||||
onKeyDown={captureEnterOrUndo}
|
||||
onPaste={(e) => {
|
||||
saveCurrentState();
|
||||
handlePasteEvent(e);
|
||||
}}
|
||||
required={true}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={(e) => {
|
||||
setFocused(false);
|
||||
adjustTextArea(e);
|
||||
}}
|
||||
value={promptInput}
|
||||
spellCheck={Appearance.get("enableSpellCheck")}
|
||||
className={`border-none cursor-text max-h-[50vh] md:max-h-[350px] md:min-h-[40px] mx-2 md:mx-0 pt-[12px] w-full leading-5 text-white bg-transparent placeholder:text-white/60 light:placeholder:text-theme-text-primary resize-none active:outline-none focus:outline-none flex-grow mb-1 pwa:!text-[16px] ${textSizeClass}`}
|
||||
placeholder={t("chat_window.send_message")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-3.5 pb-3 mx-[7px]">
|
||||
<div className="flex gap-x-2 items-center h-5 -ml-[4.5px]">
|
||||
<AttachItem
|
||||
workspaceSlug={workspaceSlug}
|
||||
workspaceThreadSlug={threadSlug}
|
||||
<div className="relative w-[95vw] md:w-[750px]">
|
||||
<ToolsMenu
|
||||
workspace={workspace}
|
||||
showing={showTools}
|
||||
setShowing={setShowTools}
|
||||
sendCommand={sendCommand}
|
||||
promptRef={textareaRef}
|
||||
centered={centered}
|
||||
highlightedIndexRef={toolsHighlightRef}
|
||||
/>
|
||||
<div className="bg-zinc-800 light:bg-white light:border light:border-slate-300 rounded-[20px] pwa:rounded-3xl flex flex-col px-5 overflow-hidden">
|
||||
<AttachmentManager attachments={attachments} />
|
||||
<div className="flex items-center">
|
||||
<textarea
|
||||
id={PROMPT_INPUT_ID}
|
||||
ref={textareaRef}
|
||||
onChange={handleChange}
|
||||
onKeyDown={captureEnterOrUndo}
|
||||
onPaste={(e) => {
|
||||
saveCurrentState();
|
||||
handlePasteEvent(e);
|
||||
}}
|
||||
required={true}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={(e) => {
|
||||
setFocused(false);
|
||||
adjustTextArea(e);
|
||||
}}
|
||||
value={promptInput}
|
||||
spellCheck={Appearance.get("enableSpellCheck")}
|
||||
className={`border-none cursor-text max-h-[50vh] md:max-h-[350px] md:min-h-[40px] pt-[20px] w-full leading-5 text-white light:text-slate-600 bg-transparent placeholder:text-white/60 light:placeholder:text-slate-400 resize-none active:outline-none focus:outline-none flex-grow pwa:!text-[16px] ${textSizeClass}`}
|
||||
placeholder={t("chat_window.send_message")}
|
||||
/>
|
||||
<SlashCommandsButton
|
||||
showing={showSlashCommand}
|
||||
setShowSlashCommand={setShowSlashCommand}
|
||||
/>
|
||||
<AvailableAgentsButton
|
||||
showing={showAgents}
|
||||
setShowAgents={setShowAgents}
|
||||
/>
|
||||
<TextSizeButton />
|
||||
<LLMSelectorAction workspaceSlug={workspaceSlug} />
|
||||
</div>
|
||||
<div className="flex gap-x-2 items-center h-5">
|
||||
<SpeechToText sendCommand={sendCommand} />
|
||||
{isStreaming ? (
|
||||
<StopGenerationButton />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
ref={formRef}
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
className="border-none inline-flex justify-center items-center rounded-full cursor-pointer w-[20px] h-[20px] light:bg-slate-800 bg-white disabled:cursor-not-allowed disabled:opacity-50 hover:opacity-80 transition-opacity"
|
||||
data-tooltip-id="send-prompt"
|
||||
data-tooltip-content={
|
||||
isDisabled
|
||||
? t("chat_window.attachments_processing")
|
||||
: t("chat_window.send")
|
||||
}
|
||||
aria-label={t("chat_window.send")}
|
||||
>
|
||||
<ArrowUp
|
||||
className="w-[12px] h-[12px] pointer-events-none light:text-white text-black"
|
||||
weight="bold"
|
||||
/>
|
||||
<span className="sr-only">Send message</span>
|
||||
</button>
|
||||
<Tooltip
|
||||
id="send-prompt"
|
||||
place="bottom"
|
||||
delayShow={300}
|
||||
className="tooltip !text-xs z-99"
|
||||
<div className="flex justify-between items-center pt-3.5 pb-3">
|
||||
<div className="flex items-center gap-x-0.25">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<AttachItem
|
||||
workspaceSlug={workspaceSlug}
|
||||
workspaceThreadSlug={threadSlug}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<AgentSessionButton
|
||||
sendCommand={sendCommand}
|
||||
promptInput={promptInput}
|
||||
textareaRef={textareaRef}
|
||||
visible={!agentSessionActive & showAgentCommand}
|
||||
/>
|
||||
</div>
|
||||
<ToolsButton
|
||||
showTools={showTools}
|
||||
setShowTools={setShowTools}
|
||||
textareaRef={textareaRef}
|
||||
autoOpenedToolsRef={autoOpenedToolsRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-x-2 items-center">
|
||||
<SpeechToText sendCommand={sendCommand} />
|
||||
{isStreaming ? (
|
||||
<StopGenerationButton />
|
||||
) : (
|
||||
<SendPromptButton
|
||||
formRef={formRef}
|
||||
promptInput={promptInput}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,6 +406,123 @@ export default function PromptInput({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSessionButton({
|
||||
sendCommand,
|
||||
promptInput,
|
||||
textareaRef,
|
||||
visible = true,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!visible) return null;
|
||||
|
||||
function handleClick() {
|
||||
try {
|
||||
if (promptInput?.trim()?.startsWith("@agent")) return;
|
||||
sendCommand({ text: "@agent", writeMode: "prepend" });
|
||||
} finally {
|
||||
textareaRef?.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
data-tooltip-id="agent-session"
|
||||
data-tooltip-content={t("chat_window.start_agent_session")}
|
||||
aria-label={t("chat_window.start_agent_session")}
|
||||
className="group border-none relative flex justify-center items-center cursor-pointer w-6 h-6 rounded-full hover:bg-zinc-700 light:hover:bg-slate-200"
|
||||
>
|
||||
<At
|
||||
size={18}
|
||||
className="pointer-events-none text-zinc-300 light:text-slate-600 group-hover:text-white light:group-hover:text-slate-600 shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<Tooltip
|
||||
id="agent-session"
|
||||
place="bottom"
|
||||
delayShow={300}
|
||||
className="tooltip !text-xs z-99"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsButton({
|
||||
showTools,
|
||||
setShowTools,
|
||||
textareaRef,
|
||||
autoOpenedToolsRef,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<button
|
||||
id="tools-btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
autoOpenedToolsRef.current = false;
|
||||
setShowTools(!showTools);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
className={`group border-none cursor-pointer flex items-center justify-center h-6 px-2 rounded-full ${
|
||||
showTools
|
||||
? "bg-zinc-700 light:bg-slate-200"
|
||||
: "hover:bg-zinc-700 light:hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
showTools
|
||||
? "text-white light:text-slate-800"
|
||||
: "text-zinc-300 light:text-slate-600 group-hover:text-white light:group-hover:text-slate-800"
|
||||
}`}
|
||||
>
|
||||
{t("chat_window.tools")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SendPromptButton({ formRef, promptInput, isDisabled }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={formRef}
|
||||
type="submit"
|
||||
disabled={isDisabled || !promptInput.trim().length}
|
||||
className={`border-none flex justify-center items-center rounded-full w-8 h-8 transition-all ${
|
||||
promptInput.trim().length && !isDisabled
|
||||
? "cursor-pointer bg-white hover:bg-zinc-200 light:bg-slate-800 light:hover:bg-slate-600"
|
||||
: "cursor-not-allowed bg-zinc-600 light:bg-slate-400"
|
||||
}`}
|
||||
data-tooltip-id="send-prompt"
|
||||
data-tooltip-content={
|
||||
isDisabled
|
||||
? t("chat_window.attachments_processing")
|
||||
: t("chat_window.send")
|
||||
}
|
||||
aria-label={t("chat_window.send")}
|
||||
>
|
||||
<ArrowUp
|
||||
className="w-[18px] h-[18px] pointer-events-none text-zinc-800 light:text-white"
|
||||
weight="bold"
|
||||
/>
|
||||
<span className="sr-only">{t("chat_window.send")}</span>
|
||||
</button>
|
||||
<Tooltip
|
||||
id="send-prompt"
|
||||
place="bottom"
|
||||
delayShow={300}
|
||||
className="tooltip !text-xs z-99"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle event listeners to prevent the send button from being used
|
||||
* for whatever reason that may we may want to prevent the user from sending a message.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user