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:
Timothy Carambat
2026-03-30 14:50:56 -07:00
382 changed files with 25002 additions and 7654 deletions

View File

@@ -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:

View File

@@ -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:

View File

@@ -4,7 +4,7 @@
name: Check package versions
concurrency:
group: build-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:

View File

@@ -5,7 +5,7 @@
name: Verify translations files
concurrency:
group: build-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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.
![Chatting](https://github.com/Mintplex-Labs/anything-llm/assets/16845892/cfc5f47c-bd91-4067-986c-f3f49621a859)
![Chatting](https://github.com/Mintplex-Labs/anything-llm/releases/download/v1.11.2/AnythingLLM720p.gif)
<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 ChatGPTwithout 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 moreinstall 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
View 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*

View File

@@ -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 | `""` | |

View File

@@ -69,7 +69,7 @@ Notes:
```yaml
image:
repository: mintplexlabs/anythingllm
tag: "1.11.1"
tag: "1.11.2"
service:
type: ClusterIP

View File

@@ -8,7 +8,7 @@ initContainers: []
image:
repository: mintplexlabs/anythingllm
pullPolicy: IfNotPresent
tag: "1.11.1"
tag: "1.11.2"
imagePullSecrets: []
nameOverride: ""

View 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");
});
});

View 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" } },
]);

View File

@@ -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;

View File

@@ -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,
}
};

View File

@@ -86,6 +86,7 @@ app.post(
} = await processSingleFile(targetFilename, {
...options,
parseOnly: true,
absolutePath: options.absolutePath || null,
});
response
.status(200)

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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"
},

View File

@@ -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,

View File

@@ -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 };

View File

@@ -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`
);

View File

@@ -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] };
}

View File

@@ -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] };
}

View File

@@ -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] };
}

View File

@@ -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`
);

View File

@@ -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] };
}

View File

@@ -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] };
}

View File

@@ -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] };
}

View File

@@ -145,7 +145,7 @@ async function asXlsx({
documents: [],
};
} finally {
trashFile(fullFilePath);
if (!options.absolutePath) trashFile(fullFilePath);
}
if (documents.length === 0) {

View File

@@ -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.`,

View File

@@ -267,6 +267,7 @@ class OCRLoader {
this.log(`Error: ${e.message}`);
return null;
} finally {
//eslint-disable-next-line
if (!worker) return;
await worker.terminate();
}

View File

@@ -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",

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -238,7 +238,7 @@ function validBaseUrl(baseUrl) {
try {
new URL(baseUrl);
return true;
} catch (e) {
} catch {
return false;
}
}

View File

@@ -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, {

View File

@@ -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}}`,

View File

@@ -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>.*)/,
];

View File

@@ -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, "");
}

View File

@@ -23,7 +23,7 @@ function validBaseUrl(baseUrl) {
try {
new URL(baseUrl);
return true;
} catch (e) {
} catch {
return false;
}
}

View File

@@ -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"

View File

@@ -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)

View File

@@ -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 \

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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}

View File

@@ -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" : ""
}`}
>

View File

@@ -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" />

View File

@@ -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}

View File

@@ -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) => {

View 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"

View File

@@ -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>

View File

@@ -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} />
)}

View File

@@ -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" />}

View File

@@ -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 };

View File

@@ -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>
);

View File

@@ -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,
};

View File

@@ -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")}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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 ? (

View File

@@ -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>
);
}
}

View File

@@ -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 ? (

View File

@@ -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>
);

View File

@@ -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)),
}}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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)}
/>
);
}

View File

@@ -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 />
</>
);

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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")

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 };
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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 }) {

View File

@@ -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,

View File

@@ -0,0 +1 @@
export const CMD_REGEX = /[^a-zA-Z0-9_-]/g;

View File

@@ -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}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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]);
}

View File

@@ -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