From: Aleksander Grygier Date: Sat, 16 May 2026 00:02:40 +0000 (+0200) Subject: ui: Restructure repo to use `tools/ui` folder and `ui` / `UI` / `llama-ui` / `LLAMA_U... X-Git-Tag: upstream/0.0.10438~1264 X-Git-Url: https://git.djapps.eu/?a=commitdiff_plain;h=59778f0196a82db32580bb649d5d839355d6d7bf;p=pkg%2Fggml%2Fsources%2Fllama.cpp ui: Restructure repo to use `tools/ui` folder and `ui` / `UI` / `llama-ui` / `LLAMA_UI` naming (#23064) * webui: Move static build output from `tools/server/public` to `build/ui` directory * refactor: Move to `tools/ui` * refactor: rename CMake variables and preprocessor defines - Rename LLAMA_BUILD_WEBUI -> LLAMA_BUILD_UI (old kept as deprecated) - Rename LLAMA_USE_PREBUILT_WEBUI -> LLAMA_USE_PREBUILT_UI (old kept as deprecated) - Backward compat: old vars auto-forward to new ones with DEPRECATION warning - Rename internal vars: WEBUI_SOURCE -> UI_SOURCE, WEBUI_SOURCE_DIR -> UI_SOURCE_DIR, etc. - Rename HF bucket: LLAMA_WEBUI_HF_BUCKET -> LLAMA_UI_HF_BUCKET - Emit both LLAMA_BUILD_WEBUI and LLAMA_BUILD_UI preprocessor defines - Emit both LLAMA_WEBUI_DEFAULT_ENABLED and LLAMA_UI_DEFAULT_ENABLED * refactor: rename CLI flags (--webui -> --ui) with backward compat - Add --ui/--no-ui (old --webui/--no-webui kept as deprecated aliases) - Add --ui-config (old --webui-config kept as deprecated alias) - Add --ui-config-file (old --webui-config-file kept as deprecated alias) - Add --ui-mcp-proxy/--no-ui-mcp-proxy (old --webui-mcp-proxy kept as deprecated) - Add new env vars: LLAMA_ARG_UI, LLAMA_ARG_UI_CONFIG, LLAMA_ARG_UI_CONFIG_FILE, LLAMA_ARG_UI_MCP_PROXY - C++ struct fields: params.ui, params.ui_config_json, params.ui_mcp_proxy added alongside old fields - Backward compat: old fields synced to new ones in g_params_to_internals * refactor: update C++ server internals with backward compat - Rename json_webui_settings -> json_ui_settings (both kept in server_context_meta) - Rename params.webui usage -> params.ui (both synced, old still works) - JSON API emits both "ui"/"ui_settings" and "webui"/"webui_settings" keys - Server routes use params.ui_mcp_proxy || params.webui_mcp_proxy - Preprocessor guards use #if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI) * refactor: rename CI/CD workflows, artifacts, and build script - Rename webui-build.yml -> ui-build.yml; artifact webui-build -> ui-build - Rename webui-publish.yml -> ui-publish.yml; var HF_BUCKET_WEBUI_STATIC_OUTPUT -> HF_BUCKET_UI_STATIC_OUTPUT - Rename server-webui.yml -> server-ui.yml; job webui-build/checks -> ui-build/checks - Update server.yml: job/artifact refs webui-build -> ui-build - Update release.yml: all webui-build/publish refs -> ui-build/publish; HF_TOKEN_WEBUI_STATIC_OUTPUT -> HF_TOKEN_UI_STATIC_OUTPUT - Update server-self-hosted.yml: webui-build -> ui-build - Update build-self-hosted.yml: HF_WEBUI_VERSION -> HF_UI_VERSION - Rename webui-download.cmake -> ui-download.cmake (internal refs updated) - Update labeler.yml: server/webui -> server/ui path label * docs: update CODEOWNERS and server README docs - Update CODEOWNERS: team ggml-org/llama-webui -> ggml-org/llama-ui, path /tools/server/webui/ -> /tools/ui/ - Update server README.md: CLI tables show --ui flags with deprecated --webui aliases - Update server README-dev.md: "WebUI" -> "UI", paths updated to tools/ui/ * fix: Small fixes for UI build * fix: CMake.txt syntax * chore: Formatting * fix: `.editorconfig` for llama-ui * chore: Formatting * refactor: Use `APP_NAME` in Error route * refactor: Cleanup * refactor: Single migration service * make llama-ui a linkable target * fix: UI Build output * fix: Missing change * fix: separate llama-ui npm build output into build/tools/ui/dist subfolder + use cmake npm build instead of downloading ui-build.yml artifacts in CI * refactor: UI workflows cleanup --------- Co-authored-by: Xuan Son Nguyen --- diff --git a/.editorconfig b/.editorconfig index 7c1af01a1..5663b8fdb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -45,7 +45,7 @@ insert_final_newline = unset trim_trailing_whitespace = unset insert_final_newline = unset -[tools/server/webui/**] +[tools/ui/**] indent_style = unset indent_size = unset end_of_line = unset diff --git a/.github/labeler.yml b/.github/labeler.yml index 2120672ee..60aa51d2c 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -73,10 +73,10 @@ android: - changed-files: - any-glob-to-any-file: - examples/llama.android/** -server/webui: +server/ui: - changed-files: - any-glob-to-any-file: - - tools/server/webui/** + - tools/ui/** server: - changed-files: - any-glob-to-any-file: diff --git a/.github/workflows/build-self-hosted.yml b/.github/workflows/build-self-hosted.yml index ee8fd41d7..2851c4560 100644 --- a/.github/workflows/build-self-hosted.yml +++ b/.github/workflows/build-self-hosted.yml @@ -68,6 +68,8 @@ jobs: - name: Determine tag name id: tag uses: ./.github/actions/get-tag-name + env: + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} ggml-ci-nvidia-cuda: needs: determine-tag @@ -81,7 +83,7 @@ jobs: - name: Test id: ggml-ci env: - HF_WEBUI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} + HF_UI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} run: | nvidia-smi GG_BUILD_CUDA=1 bash ./ci/run.sh ~/results/llama.cpp /mnt/llama.cpp @@ -98,7 +100,7 @@ jobs: - name: Test id: ggml-ci env: - HF_WEBUI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} + HF_UI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} run: | vulkaninfo --summary GG_BUILD_VULKAN=1 GGML_VK_DISABLE_COOPMAT2=1 bash ./ci/run.sh ~/results/llama.cpp /mnt/llama.cpp @@ -115,7 +117,7 @@ jobs: - name: Test id: ggml-ci env: - HF_WEBUI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} + HF_UI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} run: | vulkaninfo --summary GG_BUILD_VULKAN=1 bash ./ci/run.sh ~/results/llama.cpp /mnt/llama.cpp @@ -205,7 +207,7 @@ jobs: - name: Test id: ggml-ci env: - HF_WEBUI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} + HF_UI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} run: | GG_BUILD_METAL=1 bash ./ci/run.sh ~/results/llama.cpp ~/mnt/llama.cpp @@ -234,7 +236,7 @@ jobs: - name: Test id: ggml-ci env: - HF_WEBUI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} + HF_UI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} run: | GG_BUILD_WEBGPU=1 GG_BUILD_WEBGPU_DAWN_PREFIX="$GITHUB_WORKSPACE/dawn" \ bash ./ci/run.sh ~/results/llama.cpp ~/mnt/llama.cpp @@ -251,7 +253,7 @@ jobs: - name: Test id: ggml-ci env: - HF_WEBUI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} + HF_UI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} run: | vulkaninfo --summary GG_BUILD_VULKAN=1 bash ./ci/run.sh ~/results/llama.cpp ~/mnt/llama.cpp @@ -270,7 +272,7 @@ jobs: - name: Test id: ggml-ci env: - HF_WEBUI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} + HF_UI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} run: | vulkaninfo --summary GG_BUILD_VULKAN=1 bash ./ci/run.sh ~/results/llama.cpp ~/mnt/llama.cpp @@ -291,7 +293,7 @@ jobs: MSYSTEM: UCRT64 CHERE_INVOKING: 1 PATH: C:\msys64\ucrt64\bin;C:\msys64\usr\bin;C:\Windows\System32;${{ env.PATH }} - HF_WEBUI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} + HF_UI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} run: | vulkaninfo --summary # Skip python related tests with GG_BUILD_LOW_PERF=1 since Windows MSYS2 UCRT64 currently fails to create @@ -332,7 +334,7 @@ jobs: - name: Test id: ggml-ci env: - HF_WEBUI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} + HF_UI_VERSION: ${{ needs.determine-tag.outputs.tag_name }} run: | source ./openvino_toolkit/setupvars.sh GG_BUILD_OPENVINO=1 GGML_OPENVINO_DEVICE=GPU GG_BUILD_LOW_PERF=1 bash ./ci/run.sh ./tmp/results ./tmp/mnt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb512fd87..1880c155c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,13 +36,8 @@ env: CMAKE_ARGS: "-DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_TOOLS=ON -DLLAMA_BUILD_SERVER=ON -DGGML_RPC=ON" jobs: - webui-build: - name: Build WebUI - uses: ./.github/workflows/webui-build.yml macOS-cpu: - needs: - - webui-build strategy: matrix: @@ -71,11 +66,12 @@ jobs: with: fetch-depth: 0 - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: ccache uses: ggml-org/ccache-action@v1.2.21 @@ -113,8 +109,6 @@ jobs: name: llama-bin-macos-${{ matrix.build }}.tar.gz ubuntu-cpu: - needs: - - webui-build strategy: matrix: @@ -135,11 +129,12 @@ jobs: with: fetch-depth: 0 - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: ccache if: ${{ matrix.build != 's390x' }} @@ -191,8 +186,6 @@ jobs: name: llama-bin-ubuntu-${{ matrix.build }}.tar.gz ubuntu-vulkan: - needs: - - webui-build strategy: matrix: @@ -211,11 +204,12 @@ jobs: with: fetch-depth: 0 - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: ccache uses: ggml-org/ccache-action@v1.2.21 @@ -268,8 +262,6 @@ jobs: name: llama-bin-ubuntu-vulkan-${{ matrix.build }}.tar.gz android-arm64: - needs: - - webui-build runs-on: ubuntu-latest @@ -283,11 +275,12 @@ jobs: with: fetch-depth: 0 - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: ccache uses: ggml-org/ccache-action@v1.2.21 @@ -346,8 +339,6 @@ jobs: name: llama-bin-android-arm64.tar.gz ubuntu-24-openvino: - needs: - - webui-build runs-on: ubuntu-24.04 @@ -370,11 +361,12 @@ jobs: with: fetch-depth: 0 - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: ccache uses: ggml-org/ccache-action@v1.2.21 @@ -435,8 +427,6 @@ jobs: name: llama-bin-ubuntu-openvino-${{ env.OPENVINO_VERSION_MAJOR }}-x64.tar.gz windows-cpu: - needs: - - webui-build runs-on: windows-2025 @@ -452,11 +442,12 @@ jobs: with: fetch-depth: 0 - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: ccache uses: ggml-org/ccache-action@v1.2.21 @@ -496,8 +487,6 @@ jobs: name: llama-bin-win-cpu-${{ matrix.arch }}.zip windows: - needs: - - webui-build runs-on: windows-2025 @@ -522,11 +511,12 @@ jobs: id: checkout uses: actions/checkout@v6 - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: ccache uses: ggml-org/ccache-action@v1.2.21 @@ -587,8 +577,6 @@ jobs: name: llama-bin-win-${{ matrix.backend }}-${{ matrix.arch }}.zip windows-cuda: - needs: - - webui-build runs-on: windows-2022 @@ -601,11 +589,12 @@ jobs: id: checkout uses: actions/checkout@v6 - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: Install ccache uses: ggml-org/ccache-action@v1.2.21 @@ -667,8 +656,6 @@ jobs: name: cudart-llama-bin-win-cuda-${{ matrix.cuda }}-x64.zip windows-sycl: - needs: - - webui-build runs-on: windows-2022 @@ -708,11 +695,12 @@ jobs: Expand-Archive -Path "level-zero-win-sdk.zip" -DestinationPath "C:/level-zero-sdk" -Force "LEVEL_ZERO_V1_SDK_PATH=C:/level-zero-sdk" | Out-File -FilePath $env:GITHUB_ENV -Append - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: ccache uses: ggml-org/ccache-action@v1.2.21 @@ -781,8 +769,6 @@ jobs: name: llama-bin-win-sycl-x64.zip ubuntu-24-sycl: - needs: - - webui-build strategy: matrix: @@ -831,11 +817,12 @@ jobs: wget -q "https://github.com/oneapi-src/level-zero/releases/download/v${LEVEL_ZERO_VERSION}/level-zero-devel_${LEVEL_ZERO_VERSION}%2B${LEVEL_ZERO_UBUNTU_VERSION}_amd64.deb" -O level-zero-devel.deb sudo apt-get install -y ./level-zero.deb ./level-zero-devel.deb - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: ccache uses: ggml-org/ccache-action@v1.2.21 @@ -876,8 +863,6 @@ jobs: name: llama-bin-ubuntu-sycl-${{ matrix.build }}-x64.tar.gz ubuntu-22-rocm: - needs: - - webui-build runs-on: ubuntu-22.04 @@ -895,11 +880,12 @@ jobs: with: fetch-depth: 0 - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: Free up disk space uses: ggml-org/free-disk-space@v1.3.1 @@ -988,8 +974,6 @@ jobs: name: llama-bin-ubuntu-rocm-${{ env.ROCM_VERSION_SHORT }}-${{ matrix.build }}.tar.gz windows-hip: - needs: - - webui-build runs-on: windows-2022 @@ -1007,11 +991,12 @@ jobs: id: checkout uses: actions/checkout@v6 - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: Grab rocWMMA package id: grab_rocwmma @@ -1259,7 +1244,6 @@ jobs: runs-on: ubuntu-slim needs: - - webui-build - windows - windows-cpu - windows-cuda @@ -1404,14 +1388,14 @@ jobs: } } - webui-publish: + ui-publish: if: ${{ ( github.event_name == 'push' && github.ref == 'refs/heads/master' ) || github.event.inputs.create_release == 'true' }} needs: - release - uses: ./.github/workflows/webui-publish.yml + uses: ./.github/workflows/ui-publish.yml with: version_tag: ${{ needs.release.outputs.tag_name }} secrets: - hf_token: ${{ secrets.HF_TOKEN_WEBUI_STATIC_OUTPUT }} + hf_token: ${{ secrets.HF_TOKEN_UI_STATIC_OUTPUT }} diff --git a/.github/workflows/server-sanitize.yml b/.github/workflows/server-sanitize.yml index 4c9f447cf..53c9968ee 100644 --- a/.github/workflows/server-sanitize.yml +++ b/.github/workflows/server-sanitize.yml @@ -67,6 +67,13 @@ jobs: fetch-depth: 0 ref: ${{ github.event.inputs.sha || github.event.pull_request.head.sha || github.sha || github.head_ref || github.ref_name }} + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" + - name: Build id: cmake_build run: | diff --git a/.github/workflows/server-self-hosted.yml b/.github/workflows/server-self-hosted.yml index 117c79c2f..d3b3e4cc7 100644 --- a/.github/workflows/server-self-hosted.yml +++ b/.github/workflows/server-self-hosted.yml @@ -39,12 +39,7 @@ concurrency: cancel-in-progress: true jobs: - webui-build: - name: Build WebUI - uses: ./.github/workflows/webui-build.yml - server-metal: - needs: webui-build runs-on: [self-hosted, llama-server, macOS, ARM64] name: server-metal (${{ matrix.wf_name }}) @@ -72,11 +67,12 @@ jobs: fetch-depth: 0 ref: ${{ github.event.inputs.sha || github.event.pull_request.head.sha || github.sha || github.head_ref || github.ref_name }} - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: Build id: cmake_build diff --git a/.github/workflows/server-webui.yml b/.github/workflows/server-webui.yml deleted file mode 100644 index 72c2016ef..000000000 --- a/.github/workflows/server-webui.yml +++ /dev/null @@ -1,142 +0,0 @@ -name: Server WebUI - -on: - workflow_dispatch: - inputs: - sha: - description: 'Commit SHA1 to build' - required: false - type: string - push: - branches: - - master - paths: [ - '.github/workflows/server-webui.yml', - 'tools/server/webui/**.*', - 'tools/server/tests/**.*' - ] - pull_request: - types: [opened, synchronize, reopened] - paths: [ - '.github/workflows/server-webui.yml', - 'tools/server/webui/**.*', - 'tools/server/tests/**.*' - ] - -env: - LLAMA_LOG_COLORS: 1 - LLAMA_LOG_PREFIX: 1 - LLAMA_LOG_TIMESTAMPS: 1 - LLAMA_LOG_VERBOSITY: 10 - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - webui-build: - name: Build WebUI - uses: ./.github/workflows/webui-build.yml - - webui-checks: - name: WebUI Checks - needs: webui-build - runs-on: ubuntu-24.04-arm - continue-on-error: true - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - ref: ${{ github.event.inputs.sha || github.event.pull_request.head.sha || github.sha || github.head_ref || github.ref_name }} - - - name: Setup Node.js - id: node - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: "npm" - cache-dependency-path: "tools/server/webui/package-lock.json" - - - name: Install dependencies - id: setup - if: ${{ steps.node.conclusion == 'success' }} - run: npm ci - working-directory: tools/server/webui - - - name: Run type checking - if: ${{ always() && steps.setup.conclusion == 'success' }} - run: npm run check - working-directory: tools/server/webui - - - name: Run linting - if: ${{ always() && steps.setup.conclusion == 'success' }} - run: npm run lint - working-directory: tools/server/webui - - - name: Install Playwright browsers - id: playwright - if: ${{ always() && steps.setup.conclusion == 'success' }} - run: npx playwright install --with-deps - working-directory: tools/server/webui - - - name: Run Client tests - if: ${{ always() && steps.playwright.conclusion == 'success' }} - run: npm run test:client - working-directory: tools/server/webui - - - name: Run Unit tests - if: ${{ always() && steps.playwright.conclusion == 'success' }} - run: npm run test:unit - working-directory: tools/server/webui - - e2e-tests: - name: E2E Tests - needs: webui-build - runs-on: ubuntu-24.04-arm - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - ref: ${{ github.event.inputs.sha || github.event.pull_request.head.sha || github.sha || github.head_ref || github.ref_name }} - - - name: Setup Node.js - id: node - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: "npm" - cache-dependency-path: "tools/server/webui/package-lock.json" - - - name: Install dependencies - id: setup - if: ${{ steps.node.conclusion == 'success' }} - run: npm ci - working-directory: tools/server/webui - - - name: Build application - if: ${{ always() && steps.setup.conclusion == 'success' }} - run: npm run build - working-directory: tools/server/webui - - - name: Install Playwright browsers - id: playwright - if: ${{ always() && steps.setup.conclusion == 'success' }} - run: npx playwright install --with-deps - working-directory: tools/server/webui - - - name: Build Storybook - if: ${{ always() && steps.playwright.conclusion == 'success' }} - run: npm run build-storybook - working-directory: tools/server/webui - - - name: Run UI tests - if: ${{ always() && steps.playwright.conclusion == 'success' }} - run: npm run test:ui -- --testTimeout=60000 - working-directory: tools/server/webui - - - name: Run E2E tests - if: ${{ always() && steps.playwright.conclusion == 'success' }} - run: npm run test:e2e - working-directory: tools/server/webui diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index f12e7b736..7b9c5a3a3 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -54,12 +54,7 @@ concurrency: cancel-in-progress: true jobs: - webui-build: - name: Build WebUI - uses: ./.github/workflows/webui-build.yml - server: - needs: webui-build runs-on: ubuntu-latest name: server (${{ matrix.wf_name }}) @@ -98,11 +93,12 @@ jobs: fetch-depth: 0 ref: ${{ github.event.inputs.sha || github.event.pull_request.head.sha || github.sha || github.head_ref || github.ref_name }} - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" - name: Build id: cmake_build @@ -136,7 +132,6 @@ jobs: SLOW_TESTS=1 pytest -v -x server-windows: - needs: webui-build runs-on: windows-2022 steps: @@ -147,11 +142,10 @@ jobs: fetch-depth: 0 ref: ${{ github.event.inputs.sha || github.event.pull_request.head.sha || github.sha || github.head_ref || github.ref_name }} - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 + - name: Setup Node.js + uses: actions/setup-node@v6 with: - name: webui-build - path: tools/server/public/ + node-version: "24" - name: Build id: cmake_build diff --git a/.github/workflows/ui-build.yml b/.github/workflows/ui-build.yml new file mode 100644 index 000000000..511c96fb6 --- /dev/null +++ b/.github/workflows/ui-build.yml @@ -0,0 +1,44 @@ +name: UI Build + +on: + workflow_call: + +jobs: + build: + name: Build static output + runs-on: ubuntu-slim + env: + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" + + - name: Install dependencies + run: npm ci + working-directory: tools/ui + + - name: Build application + run: npm run build + working-directory: tools/ui + + - name: Generate checksums + run: | + cd build/tools/ui/dist + for f in *; do + sha256sum "$f" | awk '{print $1, $2}' >> checksums.txt + done + + - name: Upload built UI + uses: actions/upload-artifact@v6 + with: + name: ui-build + path: build/tools/ui/dist/ + retention-days: 1 diff --git a/.github/workflows/ui-ci.yml b/.github/workflows/ui-ci.yml new file mode 100644 index 000000000..43d6e1256 --- /dev/null +++ b/.github/workflows/ui-ci.yml @@ -0,0 +1,142 @@ +name: CI (UI) + +on: + workflow_dispatch: + inputs: + sha: + description: 'Commit SHA1 to build' + required: false + type: string + push: + branches: + - master + paths: [ + '.github/workflows/ui-ci.yml', + 'tools/ui/**.*', + 'tools/server/tests/**.*' + ] + pull_request: + types: [opened, synchronize, reopened] + paths: [ + '.github/workflows/ui-ci.yml', + 'tools/ui/**.*', + 'tools/server/tests/**.*' + ] + +env: + LLAMA_LOG_COLORS: 1 + LLAMA_LOG_PREFIX: 1 + LLAMA_LOG_TIMESTAMPS: 1 + LLAMA_LOG_VERBOSITY: 10 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + ui-build: + name: Build static output + uses: ./.github/workflows/ui-build.yml + + ui-checks: + name: UI Checks + needs: ui-build + runs-on: ubuntu-24.04-arm + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.sha || github.event.pull_request.head.sha || github.sha || github.head_ref || github.ref_name }} + + - name: Setup Node.js + id: node + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" + + - name: Install dependencies + id: setup + if: ${{ steps.node.conclusion == 'success' }} + run: npm ci + working-directory: tools/ui + + - name: Run type checking + if: ${{ always() && steps.setup.conclusion == 'success' }} + run: npm run check + working-directory: tools/ui + + - name: Run linting + if: ${{ always() && steps.setup.conclusion == 'success' }} + run: npm run lint + working-directory: tools/ui + + - name: Install Playwright browsers + id: playwright + if: ${{ always() && steps.setup.conclusion == 'success' }} + run: npx playwright install --with-deps + working-directory: tools/ui + + - name: Run Client tests + if: ${{ always() && steps.playwright.conclusion == 'success' }} + run: npm run test:client + working-directory: tools/ui + + - name: Run Unit tests + if: ${{ always() && steps.playwright.conclusion == 'success' }} + run: npm run test:unit + working-directory: tools/ui + + e2e-tests: + name: E2E Tests + needs: ui-build + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.sha || github.event.pull_request.head.sha || github.sha || github.head_ref || github.ref_name }} + + - name: Setup Node.js + id: node + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: "tools/ui/package-lock.json" + + - name: Install dependencies + id: setup + if: ${{ steps.node.conclusion == 'success' }} + run: npm ci + working-directory: tools/ui + + - name: Build application + if: ${{ always() && steps.setup.conclusion == 'success' }} + run: npm run build + working-directory: tools/ui + + - name: Install Playwright browsers + id: playwright + if: ${{ always() && steps.setup.conclusion == 'success' }} + run: npx playwright install --with-deps + working-directory: tools/ui + + - name: Build Storybook + if: ${{ always() && steps.playwright.conclusion == 'success' }} + run: npm run build-storybook + working-directory: tools/ui + + - name: Run UI tests + if: ${{ always() && steps.playwright.conclusion == 'success' }} + run: npm run test:ui -- --testTimeout=60000 + working-directory: tools/ui + + - name: Run E2E tests + if: ${{ always() && steps.playwright.conclusion == 'success' }} + run: npm run test:e2e + working-directory: tools/ui diff --git a/.github/workflows/ui-publish.yml b/.github/workflows/ui-publish.yml new file mode 100644 index 000000000..33d7415c9 --- /dev/null +++ b/.github/workflows/ui-publish.yml @@ -0,0 +1,65 @@ +name: UI Publish + +on: + workflow_call: + inputs: + version_tag: + description: 'Version tag to publish under (e.g., b1234)' + required: true + type: string + secrets: + hf_token: + description: 'Hugging Face token with write access' + required: true + +jobs: + publish: + name: Publish UI Static Output + runs-on: ubuntu-24.04-arm + + permissions: + contents: read + + env: + HF_BUCKET_NAME: ${{ vars.HF_BUCKET_UI_STATIC_OUTPUT }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Download UI build artifact + uses: actions/download-artifact@v7 + with: + name: ui-build + path: build/tools/ui/dist/ + + - name: Install Hugging Face Hub CLI + run: pip install -U huggingface_hub + + - name: Authenticate with Hugging Face + run: hf auth login --token ${{ secrets.hf_token }} + + - name: Sync built files to Hugging Face bucket (version tag) + run: | + # Upload the built files to the Hugging Face bucket under the release version + hf buckets sync build/tools/ui/dist hf://buckets/ggml-org/${{ env.HF_BUCKET_NAME }}/${{ inputs.version_tag }} --delete --quiet + + - name: Sync built files to Hugging Face bucket (latest) + run: | + # Also upload to the 'latest' directory for fallback downloads + hf buckets sync build/tools/ui/dist hf://buckets/ggml-org/${{ env.HF_BUCKET_NAME }}/latest --delete --quiet + + - name: Verify upload + run: | + # List the files in the bucket to verify the upload + hf buckets list hf://buckets/ggml-org/${{ env.HF_BUCKET_NAME }}/${{ inputs.version_tag }} -R -h + + - name: Clean up root-level files + run: | + # Clean up any old root-level files from previous non-versioned deployments + hf buckets rm ggml-org/${{ env.HF_BUCKET_NAME }}/index.html --yes 2>/dev/null || true + hf buckets rm ggml-org/${{ env.HF_BUCKET_NAME }}/bundle.js --yes 2>/dev/null || true + hf buckets rm ggml-org/${{ env.HF_BUCKET_NAME }}/bundle.css --yes 2>/dev/null || true + hf buckets rm ggml-org/${{ env.HF_BUCKET_NAME }}/loading.html --yes 2>/dev/null || true diff --git a/.github/workflows/webui-build.yml b/.github/workflows/webui-build.yml deleted file mode 100644 index 7f512a69d..000000000 --- a/.github/workflows/webui-build.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Build WebUI - -on: - workflow_call: - -jobs: - build: - name: Build WebUI - runs-on: ubuntu-slim - env: - BRANCH_NAME: ${{ github.head_ref || github.ref_name }} - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: "npm" - cache-dependency-path: "tools/server/webui/package-lock.json" - - - name: Install dependencies - run: npm ci - working-directory: tools/server/webui - - - name: Build application - run: npm run build - working-directory: tools/server/webui - - - name: Generate checksums - run: | - cd tools/server/public - for f in *; do - sha256sum "$f" | awk '{print $1, $2}' >> checksums.txt - done - - - name: Upload built webui - uses: actions/upload-artifact@v6 - with: - name: webui-build - path: tools/server/public/ - retention-days: 1 diff --git a/.github/workflows/webui-publish.yml b/.github/workflows/webui-publish.yml deleted file mode 100644 index bf0d707a2..000000000 --- a/.github/workflows/webui-publish.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: WebUI Publish - -on: - workflow_call: - inputs: - version_tag: - description: 'Version tag to publish under (e.g., b1234)' - required: true - type: string - secrets: - hf_token: - description: 'Hugging Face token with write access' - required: true - -jobs: - publish: - name: Publish WebUI Static Output - runs-on: ubuntu-24.04-arm - - permissions: - contents: read - - env: - HF_BUCKET_NAME: ${{ vars.HF_BUCKET_WEBUI_STATIC_OUTPUT }} - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - - name: Download WebUI build artifact - uses: actions/download-artifact@v7 - with: - name: webui-build - path: tools/server/public/ - - - name: Install Hugging Face Hub CLI - run: pip install -U huggingface_hub - - - name: Authenticate with Hugging Face - run: hf auth login --token ${{ secrets.hf_token }} - - - name: Sync built files to Hugging Face bucket (version tag) - run: | - # Upload the built files to the Hugging Face bucket under the release version - hf buckets sync tools/server/public hf://buckets/ggml-org/${{ env.HF_BUCKET_NAME }}/${{ inputs.version_tag }} --delete --quiet - - - name: Sync built files to Hugging Face bucket (latest) - run: | - # Also upload to the 'latest' directory for fallback downloads - hf buckets sync tools/server/public hf://buckets/ggml-org/${{ env.HF_BUCKET_NAME }}/latest --delete --quiet - - - name: Verify upload - run: | - # List the files in the bucket to verify the upload - hf buckets list hf://buckets/ggml-org/${{ env.HF_BUCKET_NAME }}/${{ inputs.version_tag }} -R -h - - - name: Clean up root-level files - run: | - # Clean up any old root-level files from previous non-versioned deployments - hf buckets rm ggml-org/${{ env.HF_BUCKET_NAME }}/index.html --yes 2>/dev/null || true - hf buckets rm ggml-org/${{ env.HF_BUCKET_NAME }}/bundle.js --yes 2>/dev/null || true - hf buckets rm ggml-org/${{ env.HF_BUCKET_NAME }}/bundle.css --yes 2>/dev/null || true - hf buckets rm ggml-org/${{ env.HF_BUCKET_NAME }}/loading.html --yes 2>/dev/null || true diff --git a/.gitignore b/.gitignore index 5f53fbf7a..8dc9d7d0b 100644 --- a/.gitignore +++ b/.gitignore @@ -54,7 +54,6 @@ /tmp/ /autogen-*.md /common/build-info.cpp -/tools/server/public # Deprecated @@ -93,10 +92,12 @@ !/examples/sycl/*.bat !/examples/sycl/*.sh -# Server Web UI temporary files +# Server Web UI temporary files (+ legacy directory) /tools/server/webui/node_modules /tools/server/webui/dist +/tools/ui/node_modules +/tools/ui/dist # Python diff --git a/CMakeLists.txt b/CMakeLists.txt index 244f4cb49..447460723 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -108,8 +108,23 @@ option(LLAMA_BUILD_TESTS "llama: build tests" option(LLAMA_BUILD_TOOLS "llama: build tools" ${LLAMA_STANDALONE}) option(LLAMA_BUILD_EXAMPLES "llama: build examples" ${LLAMA_STANDALONE}) option(LLAMA_BUILD_SERVER "llama: build server example" ${LLAMA_STANDALONE}) -option(LLAMA_BUILD_WEBUI "llama: build the embedded Web UI for server" ON) -option(LLAMA_USE_PREBUILT_WEBUI "llama: use prebuilt WebUI from HF Bucket when available (requires LLAMA_BUILD_WEBUI=ON)" ON) +# Deprecated: use LLAMA_BUILD_UI instead (kept for backward compat) +option(LLAMA_BUILD_WEBUI "llama: build the embedded Web UI for server (deprecated: use LLAMA_BUILD_UI)" ON) +option(LLAMA_USE_PREBUILT_WEBUI "llama: use prebuilt WebUI from HF Bucket when available (deprecated: use LLAMA_USE_PREBUILT_UI)" ON) + +# New option names +option(LLAMA_BUILD_UI "llama: build the embedded Web UI for server" ON) +option(LLAMA_USE_PREBUILT_UI "llama: use prebuilt UI from HF Bucket when available (requires LLAMA_BUILD_UI=ON)" ON) + +# Backward compat: when old var is set but new one isn't, forward the value +if(DEFINED LLAMA_BUILD_WEBUI AND NOT DEFINED LLAMA_BUILD_UI) + set(LLAMA_BUILD_UI ${LLAMA_BUILD_WEBUI}) + message(DEPRECATION "LLAMA_BUILD_WEBUI is deprecated, use LLAMA_BUILD_UI instead") +endif() +if(DEFINED LLAMA_USE_PREBUILT_WEBUI AND NOT DEFINED LLAMA_USE_PREBUILT_UI) + set(LLAMA_USE_PREBUILT_UI ${LLAMA_USE_PREBUILT_WEBUI}) + message(DEPRECATION "LLAMA_USE_PREBUILT_WEBUI is deprecated, use LLAMA_USE_PREBUILT_UI instead") +endif() option(LLAMA_TOOLS_INSTALL "llama: install tools" ${LLAMA_TOOLS_INSTALL_DEFAULT}) option(LLAMA_TESTS_INSTALL "llama: install tests" ON) diff --git a/CODEOWNERS b/CODEOWNERS index a4395969f..f58f0f830 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -15,7 +15,7 @@ # ggml-org/llama-common : ggerganov, aldehir, angt, danbev, ngxson, pwilkin # ggml-org/llama-mtmd : ngxson # ggml-org/llama-server : ggerganov, ngxson, allozaur, angt, ServeurpersoCom -# ggml-org/llama-webui : allozaur +# ggml-org/llama-ui : allozaur /.devops/*.Dockerfile @ngxson /.github/actions/ @ggml-org/ci @@ -107,7 +107,7 @@ /tools/rpc/ @ggml-org/ggml-rpc /tools/server/* @ggml-org/llama-server # no subdir /tools/server/tests/ @ggml-org/llama-server -/tools/server/webui/ @ggml-org/llama-webui +/tools/ui/ @ggml-org/llama-ui /tools/tokenize/ @ggerganov /tools/tts/ @ggerganov /vendor/ @ggerganov diff --git a/common/arg.cpp b/common/arg.cpp index 15d5ad77a..2129a9c72 100644 --- a/common/arg.cpp +++ b/common/arg.cpp @@ -2844,28 +2844,64 @@ common_params_context common_params_parser_init(common_params & params, llama_ex params.api_prefix = value; } ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_API_PREFIX")); + // Deprecated: use --ui-config instead (kept for backward compat) add_opt(common_arg( {"--webui-config"}, "JSON", - "JSON that provides default WebUI settings (overrides WebUI defaults)", + "[DEPRECATED: use --ui-config] JSON that provides default WebUI settings (overrides WebUI defaults)", [](common_params & params, const std::string & value) { + params.ui_config_json = value; params.webui_config_json = value; } ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_WEBUI_CONFIG")); + + add_opt(common_arg( + {"--ui-config"}, "JSON", + "JSON that provides default UI settings (overrides UI defaults)", + [](common_params & params, const std::string & value) { + params.ui_config_json = value; + params.webui_config_json = value; + } + ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_UI_CONFIG")); + + // Deprecated: use --ui-config-file instead (kept for backward compat) add_opt(common_arg( {"--webui-config-file"}, "PATH", - "JSON file that provides default WebUI settings (overrides WebUI defaults)", + "[DEPRECATED: use --ui-config-file] JSON file that provides default WebUI settings (overrides WebUI defaults)", [](common_params & params, const std::string & value) { - params.webui_config_json = read_file(value); + params.ui_config_json = read_file(value); + params.webui_config_json = params.ui_config_json; } ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_WEBUI_CONFIG_FILE")); + + add_opt(common_arg( + {"--ui-config-file"}, "PATH", + "JSON file that provides default UI settings (overrides UI defaults)", + [](common_params & params, const std::string & value) { + params.ui_config_json = read_file(value); + params.webui_config_json = params.ui_config_json; + } + ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_UI_CONFIG_FILE")); + + // Deprecated: use --ui-mcp-proxy instead (kept for backward compat) add_opt(common_arg( {"--webui-mcp-proxy"}, {"--no-webui-mcp-proxy"}, - string_format("experimental: whether to enable MCP CORS proxy - do not enable in untrusted environments (default: %s)", params.webui_mcp_proxy ? "enabled" : "disabled"), + "[DEPRECATED: use --ui-mcp-proxy/--no-ui-mcp-proxy] experimental: whether to enable MCP CORS proxy", [](common_params & params, bool value) { + params.ui_mcp_proxy = value; params.webui_mcp_proxy = value; } ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_WEBUI_MCP_PROXY")); + + add_opt(common_arg( + {"--ui-mcp-proxy"}, + {"--no-ui-mcp-proxy"}, + "experimental: whether to enable MCP CORS proxy - do not enable in untrusted environments (default: disabled)", + [](common_params & params, bool value) { + params.ui_mcp_proxy = value; + params.webui_mcp_proxy = value; + } + ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_UI_MCP_PROXY")); add_opt(common_arg( {"--tools"}, "TOOL1,TOOL2,...", "experimental: whether to enable built-in tools for AI agents - do not enable in untrusted environments (default: no tools)\n" @@ -2875,14 +2911,26 @@ common_params_context common_params_parser_init(common_params & params, llama_ex params.server_tools = parse_csv_row(value); } ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_TOOLS")); + // Deprecated: use --ui/--no-ui instead (kept for backward compat) add_opt(common_arg( {"--webui"}, {"--no-webui"}, - string_format("whether to enable the Web UI (default: %s)", params.webui ? "enabled" : "disabled"), + "[DEPRECATED: use --ui/--no-ui] whether to enable the Web UI", [](common_params & params, bool value) { + params.ui = value; params.webui = value; } ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_WEBUI")); + + add_opt(common_arg( + {"--ui"}, + {"--no-ui"}, + string_format("whether to enable the Web UI (default: %s)", params.ui ? "enabled" : "disabled"), + [](common_params & params, bool value) { + params.ui = value; + params.webui = value; + } + ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_UI")); add_opt(common_arg( {"--embedding", "--embeddings"}, string_format("restrict to only support embedding use case; use only with dedicated embedding models (default: %s)", params.embedding ? "enabled" : "disabled"), diff --git a/common/common.h b/common/common.h index 764574e23..c6223c4b5 100644 --- a/common/common.h +++ b/common/common.h @@ -604,15 +604,23 @@ struct common_params { std::map default_template_kwargs; - // webui configs -#ifdef LLAMA_WEBUI_DEFAULT_ENABLED - bool webui = LLAMA_WEBUI_DEFAULT_ENABLED != 0; + // UI configs +#ifdef LLAMA_UI_DEFAULT_ENABLED + bool ui = LLAMA_UI_DEFAULT_ENABLED != 0; +#elif defined(LLAMA_WEBUI_DEFAULT_ENABLED) + bool ui = LLAMA_WEBUI_DEFAULT_ENABLED != 0; #else - bool webui = true; // default to enabled when not set + bool ui = true; // default to enabled when not set #endif + + // Deprecated: use ui, ui_mcp_proxy, ui_config_json instead + bool webui = ui; bool webui_mcp_proxy = false; std::string webui_config_json; + bool ui_mcp_proxy = false; + std::string ui_config_json; + // "advanced" endpoints are disabled by default for better security bool endpoint_slots = true; bool endpoint_props = false; // only control POST requests, not GET diff --git a/scripts/ui-download.cmake b/scripts/ui-download.cmake new file mode 100644 index 000000000..65143642a --- /dev/null +++ b/scripts/ui-download.cmake @@ -0,0 +1,223 @@ +# Download UI assets from Hugging Face Bucket at build time +# Usage: cmake -DPUBLIC_DIR=... -DHF_BUCKET=... -DHF_VERSION=... -DASSETS="a;b;c" -P scripts/ui-download.cmake +# +# Asset provisioning priority: +# 1. Pre-built assets already in PUBLIC_DIR (cached from a previous run) +# 2. Local npm build (if NPM_DIR is provided and has package.json) +# 3. Hugging Face Bucket download (version-specific, then 'latest' fallback) + +cmake_minimum_required(VERSION 3.16) + +set(PUBLIC_DIR "" CACHE STRING "Directory to store/download assets") +set(HF_BUCKET "" CACHE STRING "Hugging Face bucket name") +set(HF_VERSION "" CACHE STRING "Version to download (empty = resolve from git)") +set(ASSETS "" CACHE STRING "Plus-separated list of asset filenames (+)") +set(STAMP_FILE "" CACHE STRING "Stamp file to create on success (optional)") +set(SOURCE_DIR "" CACHE STRING "Project source root (to resolve version from git)") +set(NPM_DIR "" CACHE STRING "UI source directory (to run npm build)") +set(HF_ENABLED "" CACHE STRING "Whether to allow HF Bucket download (ON/OFF)") + +# --------------------------------------------------------------------------- +# 1. Resolve version from git if not provided at configure time +# --------------------------------------------------------------------------- +set(RESOLVED_VERSION "${HF_VERSION}") +if("${RESOLVED_VERSION}" STREQUAL "" AND NOT "${SOURCE_DIR}" STREQUAL "") + if(EXISTS "${SOURCE_DIR}/cmake/build-info.cmake") + include("${SOURCE_DIR}/cmake/build-info.cmake") + if(NOT "${BUILD_NUMBER}" STREQUAL "" AND NOT BUILD_NUMBER EQUAL 0) + set(RESOLVED_VERSION "b${BUILD_NUMBER}") + message(STATUS "UI: resolved version from git: ${RESOLVED_VERSION}") + endif() + endif() +endif() + +# Convert + back to CMake list (+ is used as separator instead of ; to +# avoid platform-specific escaping issues when passing via -D arguments) +string(REGEX REPLACE "\\+" ";" ASSETS "${ASSETS}") + +# --------------------------------------------------------------------------- +# 2. Check stamp freshness — re-download if resolved version changed +# --------------------------------------------------------------------------- +set(FORCE_REBUILD FALSE) +if(NOT "${STAMP_FILE}" STREQUAL "" AND EXISTS "${STAMP_FILE}") + file(READ "${STAMP_FILE}" STAMPED_VERSION) + string(STRIP "${STAMPED_VERSION}" STAMPED_VERSION) + if(NOT "${STAMPED_VERSION}" STREQUAL "${RESOLVED_VERSION}") + message(STATUS "UI: version changed (${STAMPED_VERSION} -> ${RESOLVED_VERSION}), re-building") + set(FORCE_REBUILD TRUE) + endif() +endif() + +# --------------------------------------------------------------------------- +# 3. Check if assets already exist (cached from a previous run) +# --------------------------------------------------------------------------- +set(ALL_EXISTS TRUE) +foreach(asset ${ASSETS}) + if(NOT EXISTS "${PUBLIC_DIR}/${asset}") + set(ALL_EXISTS FALSE) + break() + endif() +endforeach() + +if(ALL_EXISTS AND NOT FORCE_REBUILD) + message(STATUS "UI: all assets already exist in ${PUBLIC_DIR}, skipping") + return() +endif() + +file(MAKE_DIRECTORY "${PUBLIC_DIR}") + +# --------------------------------------------------------------------------- +# 4. Priority 2: build from source via npm (fast path for developers) +# --------------------------------------------------------------------------- +set(PROVISION_SUCCESS FALSE) + +if(NOT PROVISION_SUCCESS AND NOT "${NPM_DIR}" STREQUAL "") + if(EXISTS "${NPM_DIR}/package.json") + # Check if npm is available before attempting npm build + find_program(NPM_EXECUTABLE npm) + if(NPM_EXECUTABLE) + message(STATUS "UI: building from source in ${NPM_DIR}") + + # Run npm install if node_modules is missing + if(NOT EXISTS "${NPM_DIR}/node_modules") + message(STATUS "UI: running npm install (first time)") + execute_process( + COMMAND ${NPM_EXECUTABLE} install + WORKING_DIRECTORY "${NPM_DIR}" + RESULT_VARIABLE NPM_INSTALL_RESULT + OUTPUT_VARIABLE NPM_OUT + ERROR_VARIABLE NPM_ERR + ) + if(NOT NPM_INSTALL_RESULT EQUAL 0) + message(STATUS "UI: npm install failed (${NPM_INSTALL_RESULT}), falling back to download") + message(STATUS " stderr: ${NPM_ERR}") + endif() + endif() + + # Run the build + execute_process( + COMMAND ${NPM_EXECUTABLE} run build + WORKING_DIRECTORY "${NPM_DIR}" + RESULT_VARIABLE NPM_BUILD_RESULT + OUTPUT_VARIABLE NPM_OUT + ERROR_VARIABLE NPM_ERR + ) + + if(NPM_BUILD_RESULT EQUAL 0) + # Verify that the expected assets were produced + set(ALL_BUILT TRUE) + foreach(asset ${ASSETS}) + if(NOT EXISTS "${PUBLIC_DIR}/${asset}") + set(ALL_BUILT FALSE) + break() + endif() + endforeach() + + if(ALL_BUILT) + message(STATUS "UI: local npm build succeeded") + set(PROVISION_SUCCESS TRUE) + else() + message(STATUS "UI: npm build completed but assets missing from ${PUBLIC_DIR}, falling back to download") + endif() + else() + message(STATUS "UI: npm build failed (${NPM_BUILD_RESULT}), falling back to download") + message(STATUS " stderr: ${NPM_ERR}") + endif() + else() + message(STATUS "UI: npm not found, skipping npm build and trying HF Bucket download") + endif() + else() + message(STATUS "UI: NPM_DIR (${NPM_DIR}) has no package.json, skipping npm build") + endif() +endif() + +# --------------------------------------------------------------------------- +# 5. Priority 3: download from Hugging Face Bucket (if enabled) +# --------------------------------------------------------------------------- +if(NOT PROVISION_SUCCESS AND HF_ENABLED) + # Build list of URLs to try — version-specific first, then 'latest' + set(URL_ENTRIES "") + if(NOT "${RESOLVED_VERSION}" STREQUAL "") + list(APPEND URL_ENTRIES + "version:https://huggingface.co/buckets/ggml-org/${HF_BUCKET}/resolve/${RESOLVED_VERSION}") + endif() + list(APPEND URL_ENTRIES + "latest:https://huggingface.co/buckets/ggml-org/${HF_BUCKET}/resolve/latest") + + foreach(entry ${URL_ENTRIES}) + string(REGEX REPLACE "^([^:]+):.*$" "\\1" url_label "${entry}") + string(REGEX REPLACE "^[^:]+:(.*)$" "\\1" base_url "${entry}") + + message(STATUS "UI: downloading assets from ${url_label}: ${base_url}") + + # Download each asset + set(ALL_OK TRUE) + foreach(asset ${ASSETS}) + set(download_url "${base_url}/${asset}?download=true") + set(download_path "${PUBLIC_DIR}/${asset}") + file(DOWNLOAD "${download_url}" "${download_path}" + STATUS download_status TIMEOUT 60 + ) + list(GET download_status 0 download_result) + if(NOT download_result EQUAL 0) + list(GET download_status 1 error_message) + message(STATUS "UI: failed to download ${asset} from ${url_label}: ${error_message}") + set(ALL_OK FALSE) + break() + endif() + message(STATUS "UI: downloaded ${asset}") + endforeach() + + if(NOT ALL_OK) + continue() + endif() + + # Verify checksums if the server provides them + file(DOWNLOAD "${base_url}/checksums.txt?download=true" + "${PUBLIC_DIR}/checksums.txt" + STATUS checksum_status TIMEOUT 30 + ) + list(GET checksum_status 0 checksum_result) + if(checksum_result EQUAL 0) + message(STATUS "UI: verifying checksums...") + file(STRINGS "${PUBLIC_DIR}/checksums.txt" CHECKSUMS_CONTENT) + foreach(asset ${ASSETS}) + set(download_path "${PUBLIC_DIR}/${asset}") + file(SHA256 "${download_path}" asset_hash) + string(TOLOWER "${asset_hash}" EXPECTED_HASH_LOWER) + string(REGEX MATCH "${EXPECTED_HASH_LOWER}[ \\t]+${asset}" CHECKSUM_LINE "${CHECKSUMS_CONTENT}") + if(NOT CHECKSUM_LINE) + message(WARNING "UI: checksum verification failed for ${asset}") + set(ALL_OK FALSE) + break() + endif() + endforeach() + if(ALL_OK) + message(STATUS "UI: all checksums verified") + endif() + endif() + + if(ALL_OK) + set(PROVISION_SUCCESS TRUE) + break() + endif() + endforeach() + + if(PROVISION_SUCCESS) + message(STATUS "UI: provisioning complete") + else() + message(WARNING "UI: failed to download assets from HF Bucket (${HF_BUCKET})") + endif() +endif() + +# --------------------------------------------------------------------------- +# 6. Write stamp file on success (stores resolved version for freshness check) +# --------------------------------------------------------------------------- +if(PROVISION_SUCCESS) + if(NOT "${STAMP_FILE}" STREQUAL "") + file(WRITE "${STAMP_FILE}" "${RESOLVED_VERSION}") + endif() +else() + message(WARNING "UI: no source available. Neither local build (${NPM_DIR}) nor HF Bucket download succeeded.") + message(WARNING "UI: building server without embedded UI. Set LLAMA_BUILD_UI=OFF to suppress this warning.") +endif() diff --git a/scripts/webui-download.cmake b/scripts/webui-download.cmake deleted file mode 100644 index 6695c0180..000000000 --- a/scripts/webui-download.cmake +++ /dev/null @@ -1,222 +0,0 @@ -# Download webui assets from Hugging Face Bucket at build time -# Usage: cmake -DPUBLIC_DIR=... -DHF_BUCKET=... -DHF_VERSION=... -DASSETS="a;b;c" -P scripts/webui-download.cmake -# -# Asset provisioning priority: -# 1. Pre-built assets already in PUBLIC_DIR (cached from a previous run) -# 2. Local npm build (if NPM_DIR is provided and has package.json) -# 3. Hugging Face Bucket download (version-specific, then 'latest' fallback) - -cmake_minimum_required(VERSION 3.16) - -set(PUBLIC_DIR "" CACHE STRING "Directory to store/download assets") -set(HF_BUCKET "" CACHE STRING "Hugging Face bucket name") -set(HF_VERSION "" CACHE STRING "Version to download (empty = resolve from git)") -set(ASSETS "" CACHE STRING "Plus-separated list of asset filenames (+)") -set(STAMP_FILE "" CACHE STRING "Stamp file to create on success (optional)") -set(SOURCE_DIR "" CACHE STRING "Project source root (to resolve version from git)") -set(NPM_DIR "" CACHE STRING "WebUI source directory (to run npm build)") -set(HF_ENABLED "" CACHE STRING "Whether to allow HF Bucket download (ON/OFF)") - -# --------------------------------------------------------------------------- -# 1. Resolve version from git if not provided at configure time -# --------------------------------------------------------------------------- -set(RESOLVED_VERSION "${HF_VERSION}") -if("${RESOLVED_VERSION}" STREQUAL "" AND NOT "${SOURCE_DIR}" STREQUAL "") - if(EXISTS "${SOURCE_DIR}/cmake/build-info.cmake") - include("${SOURCE_DIR}/cmake/build-info.cmake") - if(NOT "${BUILD_NUMBER}" STREQUAL "" AND NOT BUILD_NUMBER EQUAL 0) - set(RESOLVED_VERSION "${BUILD_NUMBER}") - message(STATUS "WebUI: resolved version from git: ${RESOLVED_VERSION}") - endif() - endif() -endif() - -# Convert + back to CMake list (+ is used as separator instead of ; to -# avoid platform-specific escaping issues when passing via -D arguments) -string(REGEX REPLACE "\\+" ";" ASSETS "${ASSETS}") - -# --------------------------------------------------------------------------- -# 2. Check stamp freshness — re-download if resolved version changed -# --------------------------------------------------------------------------- -set(FORCE_REBUILD FALSE) -if(NOT "${STAMP_FILE}" STREQUAL "" AND EXISTS "${STAMP_FILE}") - file(READ "${STAMP_FILE}" STAMPED_VERSION) - string(STRIP "${STAMPED_VERSION}" STAMPED_VERSION) - if(NOT "${STAMPED_VERSION}" STREQUAL "${RESOLVED_VERSION}") - message(STATUS "WebUI: version changed (${STAMPED_VERSION} -> ${RESOLVED_VERSION}), re-building") - set(FORCE_REBUILD TRUE) - endif() -endif() - -# --------------------------------------------------------------------------- -# 3. Check if assets already exist (cached from a previous run) -# --------------------------------------------------------------------------- -set(ALL_EXISTS TRUE) -foreach(asset ${ASSETS}) - if(NOT EXISTS "${PUBLIC_DIR}/${asset}") - set(ALL_EXISTS FALSE) - break() - endif() -endforeach() - -if(ALL_EXISTS AND NOT FORCE_REBUILD) - message(STATUS "WebUI: all assets already exist in ${PUBLIC_DIR}, skipping") - return() -endif() - -file(MAKE_DIRECTORY "${PUBLIC_DIR}") - -# --------------------------------------------------------------------------- -# 4. Priority 2: build from source via npm (fast path for developers) -# --------------------------------------------------------------------------- -set(PROVISION_SUCCESS FALSE) - -if(NOT PROVISION_SUCCESS AND NOT "${NPM_DIR}" STREQUAL "") - if(EXISTS "${NPM_DIR}/package.json") - # Check if npm is available before attempting npm build - find_program(NPM_EXECUTABLE npm) - if(NPM_EXECUTABLE) - message(STATUS "WebUI: building from source in ${NPM_DIR}") - - # Run npm install if node_modules is missing - if(NOT EXISTS "${NPM_DIR}/node_modules") - message(STATUS "WebUI: running npm install (first time)") - execute_process( - COMMAND npm install - WORKING_DIRECTORY "${NPM_DIR}" - RESULT_VARIABLE NPM_INSTALL_RESULT - OUTPUT_VARIABLE NPM_OUT - ERROR_VARIABLE NPM_ERR - ) - if(NOT NPM_INSTALL_RESULT EQUAL 0) - message(STATUS "WebUI: npm install failed (${NPM_INSTALL_RESULT}), falling back to download") - message(STATUS " stderr: ${NPM_ERR}") - endif() - endif() - - # Run the build - execute_process( - COMMAND npm run build - WORKING_DIRECTORY "${NPM_DIR}" - RESULT_VARIABLE NPM_BUILD_RESULT - OUTPUT_VARIABLE NPM_OUT - ERROR_VARIABLE NPM_ERR - ) - - if(NPM_BUILD_RESULT EQUAL 0) - # Verify that the expected assets were produced - set(ALL_BUILT TRUE) - foreach(asset ${ASSETS}) - if(NOT EXISTS "${PUBLIC_DIR}/${asset}") - set(ALL_BUILT FALSE) - break() - endif() - endforeach() - - if(ALL_BUILT) - message(STATUS "WebUI: local npm build succeeded") - set(PROVISION_SUCCESS TRUE) - else() - message(STATUS "WebUI: npm build completed but assets missing from ${PUBLIC_DIR}, falling back to download") - endif() - else() - message(STATUS "WebUI: npm build failed (${NPM_BUILD_RESULT}), falling back to download") - message(STATUS " stderr: ${NPM_ERR}") - endif() - else() - message(STATUS "WebUI: npm not found, skipping npm build and trying HF Bucket download") - endif() - else() - message(STATUS "WebUI: NPM_DIR (${NPM_DIR}) has no package.json, skipping npm build") - endif() -endif() - -# --------------------------------------------------------------------------- -# 5. Priority 3: download from Hugging Face Bucket (if enabled) -# --------------------------------------------------------------------------- -if(NOT PROVISION_SUCCESS AND HF_ENABLED) - # Build list of URLs to try — version-specific first, then 'latest' - set(URL_ENTRIES "") - if(NOT "${RESOLVED_VERSION}" STREQUAL "") - list(APPEND URL_ENTRIES - "version:https://huggingface.co/buckets/ggml-org/${HF_BUCKET}/resolve/${RESOLVED_VERSION}") - endif() - list(APPEND URL_ENTRIES - "latest:https://huggingface.co/buckets/ggml-org/${HF_BUCKET}/resolve/latest") - - foreach(entry ${URL_ENTRIES}) - string(REGEX REPLACE "^([^:]+):.*$" "\\1" url_label "${entry}") - string(REGEX REPLACE "^[^:]+:(.*)$" "\\1" base_url "${entry}") - - message(STATUS "WebUI: downloading assets from ${url_label}: ${base_url}") - - # Download each asset - set(ALL_OK TRUE) - foreach(asset ${ASSETS}) - set(download_url "${base_url}/${asset}?download=true") - set(download_path "${PUBLIC_DIR}/${asset}") - file(DOWNLOAD "${download_url}" "${download_path}" - STATUS download_status TIMEOUT 60 - ) - list(GET download_status 0 download_result) - if(NOT download_result EQUAL 0) - list(GET download_status 1 error_message) - message(STATUS "WebUI: failed to download ${asset} from ${url_label}: ${error_message}") - set(ALL_OK FALSE) - break() - endif() - message(STATUS "WebUI: downloaded ${asset}") - endforeach() - - if(NOT ALL_OK) - continue() - endif() - - # Verify checksums if the server provides them - file(DOWNLOAD "${base_url}/checksums.txt?download=true" - "${PUBLIC_DIR}/checksums.txt" - STATUS checksum_status TIMEOUT 30 - ) - list(GET checksum_status 0 checksum_result) - if(checksum_result EQUAL 0) - message(STATUS "WebUI: verifying checksums...") - file(STRINGS "${PUBLIC_DIR}/checksums.txt" CHECKSUMS_CONTENT) - foreach(asset ${ASSETS}) - set(download_path "${PUBLIC_DIR}/${asset}") - file(SHA256 "${download_path}" asset_hash) - string(TOLOWER "${asset_hash}" EXPECTED_HASH_LOWER) - string(REGEX MATCH "${EXPECTED_HASH_LOWER}[ \\t]+${asset}" CHECKSUM_LINE "${CHECKSUMS_CONTENT}") - if(NOT CHECKSUM_LINE) - message(WARNING "WebUI: checksum verification failed for ${asset}") - message(WARNING " downloaded file may not match expected checksum, but will be used") - endif() - endforeach() - if(ALL_OK) - message(STATUS "WebUI: all checksums verified") - endif() - endif() - - if(ALL_OK) - set(PROVISION_SUCCESS TRUE) - break() - endif() - endforeach() - - if(PROVISION_SUCCESS) - message(STATUS "WebUI: provisioning complete") - else() - message(WARNING "WebUI: failed to download assets from HF Bucket (${HF_BUCKET})") - endif() -endif() - -# --------------------------------------------------------------------------- -# 6. Write stamp file on success (stores resolved version for freshness check) -# --------------------------------------------------------------------------- -if(PROVISION_SUCCESS) - if(NOT "${STAMP_FILE}" STREQUAL "") - file(WRITE "${STAMP_FILE}" "${RESOLVED_VERSION}") - endif() -else() - message(WARNING "WebUI: no source available. Neither local build (${NPM_DIR}) nor HF Bucket download succeeded.") - message(WARNING "WebUI: building server without embedded WebUI. Set LLAMA_BUILD_WEBUI=OFF to suppress this warning.") -endif() diff --git a/scripts/xxd.cmake b/scripts/xxd.cmake index 14d275380..73f6cfff7 100644 --- a/scripts/xxd.cmake +++ b/scripts/xxd.cmake @@ -1,5 +1,5 @@ # CMake equivalent of `xxd -i ${INPUT} ${OUTPUT}` -# Usage: cmake -DINPUT=tools/server/public/index.html -DOUTPUT=tools/server/index.html.hpp -P scripts/xxd.cmake +# Usage: cmake -DINPUT=build/tools/ui/dist/index.html -DOUTPUT=build/tools/ui/dist/index.html.hpp -P scripts/xxd.cmake SET(INPUT "" CACHE STRING "Input File") SET(OUTPUT "" CACHE STRING "Output File") diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index b433c91d8..a60d3dab4 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -22,6 +22,7 @@ else() add_subdirectory(perplexity) add_subdirectory(quantize) if (LLAMA_BUILD_SERVER) + add_subdirectory(ui) add_subdirectory(cli) add_subdirectory(server) endif() diff --git a/tools/server/CMakeLists.txt b/tools/server/CMakeLists.txt index 20e4eb376..57d3e871d 100644 --- a/tools/server/CMakeLists.txt +++ b/tools/server/CMakeLists.txt @@ -40,136 +40,11 @@ set(TARGET_SRCS server-models.h ) -# Option to specify custom HF bucket for webui (defaults to llama-ui) -# Usage: cmake -B build -DLLAMA_WEBUI_HF_BUCKET=llama-ui -set(LLAMA_WEBUI_HF_BUCKET "llama-ui" CACHE STRING "Hugging Face bucket name for prebuilt webui assets") - -if (LLAMA_BUILD_WEBUI) - set(PUBLIC_ASSETS - index.html - bundle.js - bundle.css - loading.html - ) - - # Determine source of webui assets (priority: local > HF Bucket) - set(WEBUI_SOURCE "") - set(WEBUI_SOURCE_DIR "") - - # Priority 1: Check for local webui build output - set(LOCAL_WEBUI_DIR "${CMAKE_CURRENT_SOURCE_DIR}/public") - - # Verify all required assets exist before declaring local source valid - set(ALL_ASSETS_PRESENT TRUE) - foreach(asset ${PUBLIC_ASSETS}) - if(NOT EXISTS "${LOCAL_WEBUI_DIR}/${asset}") - set(ALL_ASSETS_PRESENT FALSE) - break() - endif() - endforeach() - - if(ALL_ASSETS_PRESENT) - set(WEBUI_SOURCE "local") - set(WEBUI_SOURCE_DIR "${LOCAL_WEBUI_DIR}") - message(STATUS "WebUI: using local build from ${WEBUI_SOURCE_DIR}") - endif() - - # Priority 2: Build-time asset provisioning (npm build → HF Bucket fallback) - if(NOT WEBUI_SOURCE_DIR) - # Environment variable takes precedence (e.g., from CI workflows) - if(DEFINED ENV{HF_WEBUI_VERSION}) - set(HF_WEBUI_VERSION "$ENV{HF_WEBUI_VERSION}") - # Validate against allowed characters to prevent CMake list separator - # or path-traversal issues in stamp filenames and download URLs - if(NOT HF_WEBUI_VERSION MATCHES "^[A-Za-z0-9._-]+$") - message(FATAL_ERROR "WebUI: invalid HF_WEBUI_VERSION='${HF_WEBUI_VERSION}' - must match ^[A-Za-z0-9._-]+$") - endif() - message(STATUS "WebUI: using HF_WEBUI_VERSION from environment=${HF_WEBUI_VERSION}") - elseif(DEFINED LLAMA_BUILD_NUMBER) - set(HF_WEBUI_VERSION "b${LLAMA_BUILD_NUMBER}") - message(STATUS "WebUI: using LLAMA_BUILD_NUMBER=${HF_WEBUI_VERSION}") - else() - set(HF_WEBUI_VERSION "") - message(STATUS "WebUI: version not specified (will use HF 'latest')") - endif() - - # Stamp file embeds the version tag so a changed build number triggers - # a fresh provision run on the next `cmake --build` without reconfiguring. - if("${HF_WEBUI_VERSION}" STREQUAL "") - set(WEBUI_VERSION_TAG "provisioned") - else() - set(WEBUI_VERSION_TAG "${HF_WEBUI_VERSION}") - endif() - set(WEBUI_STAMP "${CMAKE_CURRENT_BINARY_DIR}/.webui-${WEBUI_VERSION_TAG}.stamp") - - # Join assets with + separator (safe across all platforms, unlike ; and |) - string(REPLACE ";" "+" PUBLIC_ASSETS_JOINED "${PUBLIC_ASSETS}") - - add_custom_command( - OUTPUT ${WEBUI_STAMP} - COMMAND ${CMAKE_COMMAND} - "-DSOURCE_DIR=${PROJECT_SOURCE_DIR}" - "-DPUBLIC_DIR=${CMAKE_CURRENT_SOURCE_DIR}/public" - "-DHF_BUCKET=${LLAMA_WEBUI_HF_BUCKET}" - "-DHF_VERSION=${HF_WEBUI_VERSION}" - "-DHF_ENABLED=${LLAMA_USE_PREBUILT_WEBUI}" - "-DASSETS=${PUBLIC_ASSETS_JOINED}" - "-DSTAMP_FILE=${WEBUI_STAMP}" - "-DNPM_DIR=${CMAKE_CURRENT_SOURCE_DIR}/webui" - -P ${PROJECT_SOURCE_DIR}/scripts/webui-download.cmake - COMMENT "Building/provisioning WebUI assets (npm build -> HF Bucket fallback)" - ) - - set(WEBUI_SOURCE "provisioned") - set(WEBUI_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/public") - endif() - - # Process assets from the determined source - if(WEBUI_SOURCE_DIR) - foreach(asset ${PUBLIC_ASSETS}) - set(input "${WEBUI_SOURCE_DIR}/${asset}") - set(output "${CMAKE_CURRENT_BINARY_DIR}/${asset}.hpp") - list(APPEND TARGET_SRCS ${output}) - - if(WEBUI_SOURCE STREQUAL "local") - # Local build: files exist at configure time - if(NOT EXISTS "${input}") - message(FATAL_ERROR "WebUI asset not found: ${input}") - endif() - set(dependency "${input}") - else() - # HF Bucket: files are downloaded at build time - set(dependency "${WEBUI_STAMP}") - endif() - - add_custom_command( - DEPENDS ${dependency} - OUTPUT "${output}" - COMMAND "${CMAKE_COMMAND}" "-DINPUT=${input}" "-DOUTPUT=${output}" -P "${PROJECT_SOURCE_DIR}/scripts/xxd.cmake" - ) - set_source_files_properties(${output} PROPERTIES GENERATED TRUE) - endforeach() - - add_definitions(-DLLAMA_BUILD_WEBUI) - add_definitions(-DLLAMA_WEBUI_DEFAULT_ENABLED=1) - message(STATUS "WebUI: embedded with source: ${WEBUI_SOURCE}") - else() - # WebUI source not found - issue warning but don't fail the build - # The server will still build but without webui embedded - message(WARNING "WebUI: no source available. Neither local build (tools/server/public/) nor HF Bucket download succeeded.") - message(WARNING "WebUI: building server without embedded WebUI. Set LLAMA_BUILD_WEBUI=OFF to suppress this warning.") - add_definitions(-DLLAMA_WEBUI_DEFAULT_ENABLED=0) - endif() -else() - # WebUI is disabled at build time - add_definitions(-DLLAMA_WEBUI_DEFAULT_ENABLED=0) -endif() - add_executable(${TARGET} ${TARGET_SRCS}) install(TARGETS ${TARGET} RUNTIME) target_include_directories(${TARGET} PRIVATE ../mtmd) target_include_directories(${TARGET} PRIVATE ${CMAKE_SOURCE_DIR}) -target_link_libraries(${TARGET} PRIVATE server-context PUBLIC llama-common cpp-httplib ${CMAKE_THREAD_LIBS_INIT}) +target_link_libraries(${TARGET} PRIVATE server-context llama-ui PUBLIC llama-common cpp-httplib ${CMAKE_THREAD_LIBS_INIT}) target_compile_features(${TARGET} PRIVATE cxx_std_17) diff --git a/tools/server/README-dev.md b/tools/server/README-dev.md index a9c1e7385..0ff334724 100644 --- a/tools/server/README-dev.md +++ b/tools/server/README-dev.md @@ -224,7 +224,7 @@ The SvelteKit-based Web UI is introduced in this PR: https://github.com/ggml-org ### Architecture -The WebUI follows a layered architecture: +The UI follows a layered architecture: ``` Routes → Components → Hooks → Stores → Services → Storage/API @@ -234,7 +234,7 @@ Routes → Components → Hooks → Stores → Services → Storage/API - **Services** - stateless API/database communication (`ChatService`, `ModelsService`, `PropsService`, `DatabaseService`) - **Hooks** - reusable logic (`useModelChangeValidation`, `useProcessingState`) -For detailed architecture diagrams, see [`tools/server/webui/docs/`](webui/docs/): +For detailed architecture diagrams, see [`tools/ui/docs/`](../ui/docs/): - `high-level-architecture.mmd` - full architecture with all modules - `high-level-architecture-simplified.mmd` - simplified overview @@ -246,7 +246,7 @@ For detailed architecture diagrams, see [`tools/server/webui/docs/`](webui/docs/ ```sh # make sure you have Node.js installed -cd tools/server/webui +cd tools/ui npm i # run dev server (with hot reload) diff --git a/tools/server/README.md b/tools/server/README.md index 2b3a2b168..2ed7fe16e 100644 --- a/tools/server/README.md +++ b/tools/server/README.md @@ -189,11 +189,11 @@ For the full list of features, please refer to [server's changelog](https://gith | `--reuse-port` | allow multiple sockets to bind to the same port (default: disabled)
(env: LLAMA_ARG_REUSE_PORT) | | `--path PATH` | path to serve static files from (default: )
(env: LLAMA_ARG_STATIC_PATH) | | `--api-prefix PREFIX` | prefix path the server serves from, without the trailing slash (default: )
(env: LLAMA_ARG_API_PREFIX) | -| `--webui-config JSON` | JSON that provides default WebUI settings (overrides WebUI defaults)
(env: LLAMA_ARG_WEBUI_CONFIG) | -| `--webui-config-file PATH` | JSON file that provides default WebUI settings (overrides WebUI defaults)
(env: LLAMA_ARG_WEBUI_CONFIG_FILE) | -| `--webui-mcp-proxy, --no-webui-mcp-proxy` | experimental: whether to enable MCP CORS proxy - do not enable in untrusted environments (default: disabled)
(env: LLAMA_ARG_WEBUI_MCP_PROXY) | +| `--ui-config JSON` / `--webui-config JSON` (deprecated) | JSON that provides default UI settings (overrides UI defaults)
(env: LLAMA_ARG_UI_CONFIG / LLAMA_ARG_WEBUI_CONFIG) | +| `--ui-config-file PATH` / `--webui-config-file PATH` (deprecated) | JSON file that provides default UI settings (overrides UI defaults)
(env: LLAMA_ARG_UI_CONFIG_FILE / LLAMA_ARG_WEBUI_CONFIG_FILE) | +| `--ui-mcp-proxy, --no-ui-mcp-proxy` / `--webui-mcp-proxy, --no-webui-mcp-proxy` (deprecated) | experimental: whether to enable MCP CORS proxy - do not enable in untrusted environments (default: disabled)
(env: LLAMA_ARG_UI_MCP_PROXY / LLAMA_ARG_WEBUI_MCP_PROXY) | | `--tools TOOL1,TOOL2,...` | experimental: whether to enable built-in tools for AI agents - do not enable in untrusted environments (default: no tools)
specify "all" to enable all tools
available tools: read_file, file_glob_search, grep_search, exec_shell_command, write_file, edit_file, apply_diff, get_datetime
(env: LLAMA_ARG_TOOLS) | -| `--webui, --no-webui` | whether to enable the Web UI (default: enabled)
(env: LLAMA_ARG_WEBUI) | +| `--ui, --no-ui` / `--webui, --no-webui` (deprecated) | whether to enable the Web UI (default: enabled)
(env: LLAMA_ARG_UI / LLAMA_ARG_WEBUI) | | `--embedding, --embeddings` | restrict to only support embedding use case; use only with dedicated embedding models (default: disabled)
(env: LLAMA_ARG_EMBEDDINGS) | | `--rerank, --reranking` | enable reranking endpoint on server (default: disabled)
(env: LLAMA_ARG_RERANKING) | | `--api-key KEY` | API key to use for authentication, multiple keys can be provided as a comma-separated list (default: none)
(env: LLAMA_API_KEY) | @@ -1831,10 +1831,12 @@ Apart from error types supported by OAI, we also have custom types that are spec ### Custom default Web UI preferences -You can specify default preferences for the web UI using `--webui-config ` or `--webui-config-file `. For example, you can disable pasting long text as attachments and enable rendering Markdown in user messages with this command: +You can specify default preferences for the web UI using `--ui-config ` or `--ui-config-file `. For example, you can disable pasting long text as attachments and enable rendering Markdown in user messages with this command: ```bash -./llama-server -m model.gguf --webui-config '{"pasteLongTextToFileLen": 0, "renderUserContentAsMarkdown": true}' +./llama-server -m model.gguf --ui-config '{"pasteLongTextToFileLen": 0, "renderUserContentAsMarkdown": true}' ``` -You may find available preferences in [settings-config.ts](webui/src/lib/constants/settings-config.ts). +> **Note:** The old flags `--webui-config` and `--webui-config-file` are deprecated but still work as aliases. + +You may find available preferences in [settings-config.ts](../ui/src/lib/constants/settings-config.ts). diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp index d49c986fe..1dc195368 100644 --- a/tools/server/server-context.cpp +++ b/tools/server/server-context.cpp @@ -671,7 +671,8 @@ private: server_metrics metrics; - json json_webui_settings = json::object(); + json json_ui_settings = json::object(); // Primary: new name + json json_webui_settings = json::object(); // Deprecated: use json_ui_settings instead (kept for compat) // Necessary similarity of prompt for slot selection float slot_prompt_similarity = 0.0f; @@ -996,13 +997,18 @@ private: } } - // populate webui settings + // populate UI settings (from either new ui_config_json or deprecated webui_config_json) { - if (!params_base.webui_config_json.empty()) { + const std::string & cfg = !params_base.ui_config_json.empty() + ? params_base.ui_config_json + : params_base.webui_config_json; + if (!cfg.empty()) { try { - json_webui_settings = json::parse(params_base.webui_config_json); + json json_settings = json::parse(cfg); + json_ui_settings = json_settings; + json_webui_settings = json_settings; // deprecated: keep in sync } catch (const std::exception & e) { - SRV_ERR("%s: failed to parse webui config: %s\n", __func__, e.what()); + SRV_ERR("%s: failed to parse UI config: %s\n", __func__, e.what()); return false; } } @@ -3292,7 +3298,8 @@ server_context_meta server_context::get_meta() const { /* has_mtmd */ impl->mctx != nullptr, /* has_inp_image */ impl->chat_params.allow_image, /* has_inp_audio */ impl->chat_params.allow_audio, - /* json_webui_settings */ impl->json_webui_settings, + /* json_ui_settings */ impl->json_ui_settings, + /* json_webui_settings */ impl->json_webui_settings, // Deprecated /* slot_n_ctx */ impl->get_slot_n_ctx(), /* pooling_type */ llama_pooling_type(impl->ctx_tgt), @@ -3814,8 +3821,12 @@ void server_routes::init_routes() { { "endpoint_slots", params.endpoint_slots }, { "endpoint_props", params.endpoint_props }, { "endpoint_metrics", params.endpoint_metrics }, - { "webui", params.webui }, - { "webui_settings", meta->json_webui_settings }, + // New keys + { "ui", params.ui }, + { "ui_settings", meta->json_ui_settings }, + // Deprecated: use ui/ui_settings instead (kept for backward compat) + { "webui", params.webui }, + { "webui_settings", meta->json_webui_settings }, { "chat_template", tmpl_default }, { "chat_template_caps", meta->chat_template_caps }, { "bos_token", meta->bos_token_str }, diff --git a/tools/server/server-context.h b/tools/server/server-context.h index 58dda8914..65853438c 100644 --- a/tools/server/server-context.h +++ b/tools/server/server-context.h @@ -21,7 +21,8 @@ struct server_context_meta { bool has_mtmd; bool has_inp_image; bool has_inp_audio; - json json_webui_settings; + json json_ui_settings; // Primary: new name + json json_webui_settings; // Deprecated: use json_ui_settings instead (kept for backward compat) int slot_n_ctx; enum llama_pooling_type pooling_type; diff --git a/tools/server/server-http.cpp b/tools/server/server-http.cpp index af4536fdd..39a21f4ec 100644 --- a/tools/server/server-http.cpp +++ b/tools/server/server-http.cpp @@ -1,6 +1,7 @@ #include "common.h" #include "server-http.h" #include "server-common.h" +#include "ui.h" #include @@ -10,14 +11,6 @@ #include #include -#ifdef LLAMA_BUILD_WEBUI -// auto generated files (see README.md for details) -#include "index.html.hpp" -#include "bundle.js.hpp" -#include "bundle.css.hpp" -#include "loading.html.hpp" -#endif - // // HTTP implementation using cpp-httplib // @@ -238,10 +231,11 @@ bool server_http_context::init(const common_params & params) { }; auto middleware_server_state = [this](const httplib::Request & req, httplib::Response & res) { - (void)req; // suppress unused parameter warning when LLAMA_BUILD_WEBUI is not defined + (void)req; // suppress unused parameter warning when LLAMA_BUILD_UI / LLAMA_BUILD_WEBUI is not defined bool ready = is_ready.load(); if (!ready) { -#ifdef LLAMA_BUILD_WEBUI +// Support both old and new preprocessor defines +#if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI) auto tmp = string_split(req.path, '.'); if (req.path == "/" || (tmp.size() > 0 && tmp.back() == "html")) { res.status = 503; @@ -305,8 +299,10 @@ bool server_http_context::init(const common_params & params) { // Web UI setup // - if (!params.webui) { - SRV_INF("%s", "the WebUI is disabled\n"); + // Use new `params.ui` field (backed by old `params.webui` for compat) + if (!params.ui) { + SRV_INF("%s", "The UI is disabled\n"); + SRV_INF("%s", "Use --ui/--no-ui (or deprecated --webui/--no-webui) to enable/disable\n"); } else { // register static assets routes if (!params.public_path.empty()) { @@ -317,7 +313,8 @@ bool server_http_context::init(const common_params & params) { return 1; } } else { -#ifdef LLAMA_BUILD_WEBUI +// Support both old and new preprocessor defines +#if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI) // using embedded static index.html srv->Get(params.api_prefix + "/", [](const httplib::Request & /*req*/, httplib::Response & res) { // COEP and COOP headers, required by pyodide (python interpreter) diff --git a/tools/server/server-models.cpp b/tools/server/server-models.cpp index 698489a11..433d2d8f0 100644 --- a/tools/server/server-models.cpp +++ b/tools/server/server-models.cpp @@ -1152,14 +1152,17 @@ void server_models_routes::init_routes() { {"role", "router"}, {"max_instances", params.models_max}, {"models_autoload", params.models_autoload}, - // this is a dummy response to make sure webui doesn't break + // this is a dummy response to make sure the UI doesn't break {"model_alias", "llama-server"}, {"model_path", "none"}, {"default_generation_settings", { {"params", json{}}, {"n_ctx", 0}, }}, - {"webui_settings", webui_settings}, + // New key + {"ui_settings", ui_settings}, + // Deprecated: use ui_settings instead (kept for backward compat) + {"webui_settings", webui_settings}, {"build_info", std::string(llama_build_info())}, }); return res; diff --git a/tools/server/server-models.h b/tools/server/server-models.h index f1206c714..e96d76c91 100644 --- a/tools/server/server-models.h +++ b/tools/server/server-models.h @@ -175,15 +175,22 @@ public: struct server_models_routes { common_params params; - json webui_settings = json::object(); + json ui_settings = json::object(); // Primary: new name + json webui_settings = json::object(); // Deprecated: use ui_settings (kept for compat) server_models models; server_models_routes(const common_params & params, int argc, char ** argv) : params(params), models(params, argc, argv) { - if (!this->params.webui_config_json.empty()) { + // Support both new ui_config_json and deprecated webui_config_json + const std::string & cfg = !this->params.ui_config_json.empty() + ? this->params.ui_config_json + : this->params.webui_config_json; + if (!cfg.empty()) { try { - webui_settings = json::parse(this->params.webui_config_json); + json json_settings = json::parse(cfg); + ui_settings = json_settings; + webui_settings = json_settings; // Deprecated: keep in sync } catch (const std::exception & e) { - LOG_ERR("%s: failed to parse webui config: %s\n", __func__, e.what()); + LOG_ERR("%s: failed to parse UI config: %s\n", __func__, e.what()); throw; } } diff --git a/tools/server/server.cpp b/tools/server/server.cpp index 823ae5bda..a23255078 100644 --- a/tools/server/server.cpp +++ b/tools/server/server.cpp @@ -208,7 +208,8 @@ int main(int argc, char ** argv) { ctx_http.register_gcp_compat(); // CORS proxy (EXPERIMENTAL, only used by the Web UI for MCP) - if (params.webui_mcp_proxy) { + // Supports both new ui_mcp_proxy and deprecated webui_mcp_proxy fields + if (params.ui_mcp_proxy || params.webui_mcp_proxy) { SRV_WRN("%s", "-----------------\n"); SRV_WRN("%s", "CORS proxy is enabled, do not expose server to untrusted environments\n"); SRV_WRN("%s", "This feature is EXPERIMENTAL and may be removed or changed in future versions\n"); diff --git a/tools/server/webui/.gitignore b/tools/server/webui/.gitignore deleted file mode 100644 index 051d884b0..000000000 --- a/tools/server/webui/.gitignore +++ /dev/null @@ -1,28 +0,0 @@ -test-results -node_modules - -# Output -.output -.vercel -.netlify -.wrangler -/.svelte-kit -/build - -# OS -.DS_Store -Thumbs.db - -# Env -.env -.env.* -!.env.example -!.env.test - -# Vite -vite.config.js.timestamp-* -vite.config.ts.timestamp-* - -*storybook.log -storybook-static -*.code-workspace \ No newline at end of file diff --git a/tools/server/webui/.npmrc b/tools/server/webui/.npmrc deleted file mode 100644 index b6f27f135..000000000 --- a/tools/server/webui/.npmrc +++ /dev/null @@ -1 +0,0 @@ -engine-strict=true diff --git a/tools/server/webui/.prettierignore b/tools/server/webui/.prettierignore deleted file mode 100644 index 7d74fe246..000000000 --- a/tools/server/webui/.prettierignore +++ /dev/null @@ -1,9 +0,0 @@ -# Package Managers -package-lock.json -pnpm-lock.yaml -yarn.lock -bun.lock -bun.lockb - -# Miscellaneous -/static/ diff --git a/tools/server/webui/.prettierrc b/tools/server/webui/.prettierrc deleted file mode 100644 index 8103a0b5d..000000000 --- a/tools/server/webui/.prettierrc +++ /dev/null @@ -1,16 +0,0 @@ -{ - "useTabs": true, - "singleQuote": true, - "trailingComma": "none", - "printWidth": 100, - "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], - "overrides": [ - { - "files": "*.svelte", - "options": { - "parser": "svelte" - } - } - ], - "tailwindStylesheet": "./src/app.css" -} diff --git a/tools/server/webui/.storybook/decorators/ModeWatcherDecorator.svelte b/tools/server/webui/.storybook/decorators/ModeWatcherDecorator.svelte deleted file mode 100644 index 8bded8b3f..000000000 --- a/tools/server/webui/.storybook/decorators/ModeWatcherDecorator.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - - - -{#if children} - {@const Component = children} - - -{/if} diff --git a/tools/server/webui/.storybook/decorators/TooltipProviderDecorator.svelte b/tools/server/webui/.storybook/decorators/TooltipProviderDecorator.svelte deleted file mode 100644 index ba0cabc56..000000000 --- a/tools/server/webui/.storybook/decorators/TooltipProviderDecorator.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - - {@render children()} - diff --git a/tools/server/webui/.storybook/main.ts b/tools/server/webui/.storybook/main.ts deleted file mode 100644 index 4f6945f21..000000000 --- a/tools/server/webui/.storybook/main.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { StorybookConfig } from '@storybook/sveltekit'; -import { dirname, resolve } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const config: StorybookConfig = { - stories: ['../tests/stories/**/*.mdx', '../tests/stories/**/*.stories.@(js|ts|svelte)'], - addons: [ - '@storybook/addon-svelte-csf', - '@chromatic-com/storybook', - '@storybook/addon-vitest', - '@storybook/addon-a11y', - '@storybook/addon-docs' - ], - framework: '@storybook/sveltekit', - viteFinal: async (config) => { - config.server = config.server || {}; - config.server.fs = config.server.fs || {}; - config.server.fs.allow = [...(config.server.fs.allow || []), resolve(__dirname, '../tests')]; - return config; - } -}; -export default config; diff --git a/tools/server/webui/.storybook/preview.ts b/tools/server/webui/.storybook/preview.ts deleted file mode 100644 index 4610229a6..000000000 --- a/tools/server/webui/.storybook/preview.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Preview } from '@storybook/sveltekit'; -import '../src/app.css'; -import ModeWatcherDecorator from './decorators/ModeWatcherDecorator.svelte'; -import TooltipProviderDecorator from './decorators/TooltipProviderDecorator.svelte'; - -const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i - } - }, - - backgrounds: { - disabled: true - }, - - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely - test: 'todo' - } - }, - decorators: [ - (story) => ({ - Component: ModeWatcherDecorator, - props: { - children: story - } - }), - (story) => ({ - Component: TooltipProviderDecorator, - props: { - children: story - } - }) - ] -}; - -export default preview; diff --git a/tools/server/webui/.storybook/vitest.setup.ts b/tools/server/webui/.storybook/vitest.setup.ts deleted file mode 100644 index 147157289..000000000 --- a/tools/server/webui/.storybook/vitest.setup.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; -import { setProjectAnnotations } from '@storybook/sveltekit'; -import * as previewAnnotations from './preview'; -import { beforeAll } from 'vitest'; - -const project = setProjectAnnotations([a11yAddonAnnotations, previewAnnotations]); - -beforeAll(async () => { - if (project.beforeAll) { - await project.beforeAll(); - } -}); diff --git a/tools/server/webui/README.md b/tools/server/webui/README.md deleted file mode 100644 index 40742b00e..000000000 --- a/tools/server/webui/README.md +++ /dev/null @@ -1,687 +0,0 @@ -# llama-ui - -A modern, feature-rich web interface for llama-server built with SvelteKit. This UI provides an intuitive chat interface with advanced file handling, conversation management, and comprehensive model interaction capabilities. - -The WebUI supports two server operation modes: - -- **MODEL mode** - Single model operation (standard llama-server) -- **ROUTER mode** - Multi-model operation with dynamic model loading/unloading - ---- - -## Table of Contents - -- [Features](#features) -- [Getting Started](#getting-started) -- [Tech Stack](#tech-stack) -- [Build Pipeline](#build-pipeline) -- [Architecture](#architecture) -- [Data Flows](#data-flows) -- [Architectural Patterns](#architectural-patterns) -- [Testing](#testing) - ---- - -## Features - -### Chat Interface - -- **Streaming responses** with real-time updates -- **Reasoning content** - Support for models with thinking/reasoning blocks -- **Dark/light theme** with system preference detection -- **Responsive design** for desktop and mobile - -### File Attachments - -- **Images** - JPEG, PNG, GIF, WebP, SVG (with PNG conversion) -- **Documents** - PDF (text extraction or image conversion for vision models) -- **Audio** - MP3, WAV for audio-capable models -- **Text files** - Source code, markdown, and other text formats -- **Drag-and-drop** and paste support with rich previews - -### Conversation Management - -- **Branching** - Branch messages conversations at any point by editing messages or regenerating responses, navigate between branches -- **Regeneration** - Regenerate responses with optional model switching (ROUTER mode) -- **Import/Export** - JSON format for backup and sharing -- **Search** - Find conversations by title or content - -### Advanced Rendering - -- **Syntax highlighting** - Code blocks with language detection -- **Math formulas** - KaTeX rendering for LaTeX expressions -- **Markdown** - Full GFM support with tables, lists, and more - -### Multi-Model Support (ROUTER mode) - -- **Model selector** with Loaded/Available groups -- **Automatic loading** - Models load on selection -- **Modality validation** - Prevents sending images to non-vision models -- **LRU unloading** - Server auto-manages model cache - -### Keyboard Shortcuts - -| Shortcut | Action | -| ------------------ | -------------------- | -| `Shift+Ctrl/Cmd+O` | New chat | -| `Shift+Ctrl/Cmd+E` | Edit conversation | -| `Shift+Ctrl/Cmd+D` | Delete conversation | -| `Ctrl/Cmd+K` | Search conversations | -| `Ctrl/Cmd+B` | Toggle sidebar | - -### Developer Experience - -- **Request tracking** - Monitor token generation with `/slots` endpoint -- **Storybook** - Component library with visual testing -- **Hot reload** - Instant updates during development - ---- - -## Getting Started - -### Prerequisites - -- **Node.js** 18+ (20+ recommended) -- **npm** 9+ -- **llama-server** running locally (for API access) - -### 1. Install Dependencies - -```bash -cd tools/server/webui -npm install -``` - -### 2. Start llama-server - -In a separate terminal, start the backend server: - -```bash -# Single model (MODEL mode) -./llama-server -m model.gguf - -# Multi-model (ROUTER mode) -./llama-server --models-dir /path/to/models -``` - -### 3. Start Development Servers - -```bash -npm run dev -``` - -This starts: - -- **Vite dev server** at `http://localhost:5173` - The main WebUI -- **Storybook** at `http://localhost:6006` - Component documentation - -The Vite dev server proxies API requests to `http://localhost:8080` (default llama-server port): - -```typescript -// vite.config.ts proxy configuration -proxy: { - '/v1': 'http://localhost:8080', - '/props': 'http://localhost:8080', - '/slots': 'http://localhost:8080', - '/models': 'http://localhost:8080' -} -``` - -### Development Workflow - -1. Open `http://localhost:5173` in your browser -2. Make changes to `.svelte`, `.ts`, or `.css` files -3. Changes hot-reload instantly -4. Use Storybook at `http://localhost:6006` for isolated component development - ---- - -## Tech Stack - -| Layer | Technology | Purpose | -| ----------------- | ------------------------------- | -------------------------------------------------------- | -| **Framework** | SvelteKit + Svelte 5 | Reactive UI with runes (`$state`, `$derived`, `$effect`) | -| **UI Components** | shadcn-svelte + bits-ui | Accessible, customizable component library | -| **Styling** | TailwindCSS 4 | Utility-first CSS with design tokens | -| **Database** | IndexedDB (Dexie) | Client-side storage for conversations and messages | -| **Build** | Vite | Fast bundling with static adapter | -| **Testing** | Playwright + Vitest + Storybook | E2E, unit, and visual testing | -| **Markdown** | remark + rehype | Markdown processing with KaTeX and syntax highlighting | - -### Key Dependencies - -```json -{ - "svelte": "^5.0.0", - "bits-ui": "^2.8.11", - "dexie": "^4.0.11", - "pdfjs-dist": "^5.4.54", - "highlight.js": "^11.11.1", - "rehype-katex": "^7.0.1" -} -``` - ---- - -## Build Pipeline - -### Development Build - -```bash -npm run dev -``` - -Runs Vite in development mode with: - -- Hot Module Replacement (HMR) -- Source maps -- Proxy to llama-server - -### Production Build - -```bash -npm run build -``` - -The build process: - -1. **Vite Build** - Bundles all TypeScript, Svelte, and CSS -2. **Static Adapter** - Outputs to `../public` (llama-server's static file directory) -3. **Post-Build Script** - Cleans up intermediate files -4. **Custom Plugin** - Creates `index.html` with: - - Inlined favicon as base64 - - GZIP compression (level 9) - - Deterministic output (zeroed timestamps) - -```text -tools/server/webui/ → build → tools/server/public/ -├── src/ ├── index.html (served by llama-server) -├── static/ └── (favicon inlined) -└── ... -``` - -### SvelteKit Configuration - -```javascript -// svelte.config.js -adapter: adapter({ - pages: '../public', // Output directory - assets: '../public', // Static assets - fallback: 'index.html', // SPA fallback - strict: true -}), -output: { - bundleStrategy: 'inline' // Single-file bundle -} -``` - -### Integration with llama-server - -The WebUI is embedded directly into the llama-server binary: - -1. `npm run build` outputs `index.html` to `tools/server/public/` -2. llama-server compiles this into the binary at build time -3. When accessing `/`, llama-server serves the gzipped HTML -4. All assets are inlined (CSS, JS, fonts, favicon) - -This results in a **single portable binary** with the full WebUI included. - ---- - -## Architecture - -The WebUI follows a layered architecture with unidirectional data flow: - -```text -Routes → Components → Hooks → Stores → Services → Storage/API -``` - -### High-Level Architecture - -See: [`docs/architecture/high-level-architecture-simplified.md`](docs/architecture/high-level-architecture-simplified.md) - -```mermaid -flowchart TB - subgraph Routes["📍 Routes"] - R1["/ (Welcome)"] - R2["/chat/[id]"] - RL["+layout.svelte"] - end - - subgraph Components["🧩 Components"] - C_Sidebar["ChatSidebar"] - C_Screen["ChatScreen"] - C_Form["ChatForm"] - C_Messages["ChatMessages"] - C_ModelsSelector["ModelsSelector"] - C_Settings["ChatSettings"] - end - - subgraph Stores["🗄️ Stores"] - S1["chatStore"] - S2["conversationsStore"] - S3["modelsStore"] - S4["serverStore"] - S5["settingsStore"] - end - - subgraph Services["⚙️ Services"] - SV1["ChatService"] - SV2["ModelsService"] - SV3["PropsService"] - SV4["DatabaseService"] - end - - subgraph Storage["💾 Storage"] - ST1["IndexedDB"] - ST2["LocalStorage"] - end - - subgraph APIs["🌐 llama-server"] - API1["/v1/chat/completions"] - API2["/props"] - API3["/models/*"] - end - - R1 & R2 --> C_Screen - RL --> C_Sidebar - C_Screen --> C_Form & C_Messages & C_Settings - C_Screen --> S1 & S2 - C_ModelsSelector --> S3 & S4 - S1 --> SV1 & SV4 - S3 --> SV2 & SV3 - SV4 --> ST1 - SV1 --> API1 - SV2 --> API3 - SV3 --> API2 -``` - -### Layer Breakdown - -#### Routes (`src/routes/`) - -- **`/`** - Welcome screen, creates new conversation -- **`/chat/[id]`** - Active chat interface -- **`+layout.svelte`** - Sidebar, navigation, global initialization - -#### Components (`src/lib/components/`) - -Components are organized in `app/` (application-specific) and `ui/` (shadcn-svelte primitives). - -**Chat Components** (`app/chat/`): - -| Component | Responsibility | -| ------------------ | --------------------------------------------------------------------------- | -| `ChatScreen/` | Main chat container, coordinates message list, input form, and attachments | -| `ChatForm/` | Message input textarea with file upload, paste handling, keyboard shortcuts | -| `ChatMessages/` | Message list with branch navigation, regenerate/continue/edit actions | -| `ChatAttachments/` | File attachment previews, drag-and-drop, PDF/image/audio handling | -| `ChatSettings/` | Parameter sliders (temperature, top-p, etc.) with server default sync | -| `ChatSidebar/` | Conversation list, search, import/export, navigation | - -**Dialog Components** (`app/dialogs/`): - -| Component | Responsibility | -| ------------------------------- | -------------------------------------------------------- | -| `DialogChatSettings` | Full-screen settings configuration | -| `DialogModelInformation` | Model details (context size, modalities, parallel slots) | -| `DialogChatAttachmentPreview` | Full preview for images, PDFs (text or page view), code | -| `DialogConfirmation` | Generic confirmation for destructive actions | -| `DialogConversationTitleUpdate` | Edit conversation title | - -**Server/Model Components** (`app/server/`, `app/models/`): - -| Component | Responsibility | -| ------------------- | --------------------------------------------------------- | -| `ServerErrorSplash` | Error display when server is unreachable | -| `ModelsSelector` | Model dropdown with Loaded/Available groups (ROUTER mode) | - -**Shared UI Components** (`app/misc/`): - -| Component | Responsibility | -| -------------------------------- | ---------------------------------------------------------------- | -| `MarkdownContent` | Markdown rendering with KaTeX, syntax highlighting, copy buttons | -| `SyntaxHighlightedCode` | Code blocks with language detection and highlighting | -| `ActionButton`, `ActionDropdown` | Reusable action buttons and menus | -| `BadgeModality`, `BadgeInfo` | Status and capability badges | - -#### Hooks (`src/lib/hooks/`) - -- **`useModelChangeValidation`** - Validates model switch against conversation modalities -- **`useProcessingState`** - Tracks streaming progress and token generation - -#### Stores (`src/lib/stores/`) - -| Store | Responsibility | -| -------------------- | --------------------------------------------------------- | -| `chatStore` | Message sending, streaming, abort control, error handling | -| `conversationsStore` | CRUD for conversations, message branching, navigation | -| `modelsStore` | Model list, selection, loading/unloading (ROUTER) | -| `serverStore` | Server properties, role detection, modalities | -| `settingsStore` | User preferences, parameter sync with server defaults | - -#### Services (`src/lib/services/`) - -| Service | Responsibility | -| ---------------------- | ----------------------------------------------- | -| `ChatService` | API calls to`/v1/chat/completions`, SSE parsing | -| `ModelsService` | `/models`, `/models/load`, `/models/unload` | -| `PropsService` | `/props`, `/props?model=` | -| `DatabaseService` | IndexedDB operations via Dexie | -| `ParameterSyncService` | Syncs settings with server defaults | - ---- - -## Data Flows - -### MODEL Mode (Single Model) - -See: [`docs/flows/data-flow-simplified-model-mode.md`](docs/flows/data-flow-simplified-model-mode.md) - -```mermaid -sequenceDiagram - participant User - participant UI - participant Stores - participant DB as IndexedDB - participant API as llama-server - - Note over User,API: Initialization - UI->>Stores: initialize() - Stores->>DB: load conversations - Stores->>API: GET /props - API-->>Stores: server config - Stores->>API: GET /v1/models - API-->>Stores: single model (auto-selected) - - Note over User,API: Chat Flow - User->>UI: send message - Stores->>DB: save user message - Stores->>API: POST /v1/chat/completions (stream) - loop streaming - API-->>Stores: SSE chunks - Stores-->>UI: reactive update - end - Stores->>DB: save assistant message -``` - -### ROUTER Mode (Multi-Model) - -See: [`docs/flows/data-flow-simplified-router-mode.md`](docs/flows/data-flow-simplified-router-mode.md) - -```mermaid -sequenceDiagram - participant User - participant UI - participant Stores - participant API as llama-server - - Note over User,API: Initialization - Stores->>API: GET /props - API-->>Stores: {role: "router"} - Stores->>API: GET /models - API-->>Stores: models[] with status - - Note over User,API: Model Selection - User->>UI: select model - alt model not loaded - Stores->>API: POST /models/load - loop poll status - Stores->>API: GET /models - end - Stores->>API: GET /props?model=X - end - Stores->>Stores: validate modalities - - Note over User,API: Chat Flow - Stores->>API: POST /v1/chat/completions {model: X} - loop streaming - API-->>Stores: SSE chunks + model info - end -``` - -### Detailed Flow Diagrams - -| Flow | Description | File | -| ------------- | ------------------------------------------ | ----------------------------------------------------------- | -| Chat | Message lifecycle, streaming, regeneration | [`chat-flow.md`](docs/flows/chat-flow.md) | -| Models | Loading, unloading, modality caching | [`models-flow.md`](docs/flows/models-flow.md) | -| Server | Props fetching, role detection | [`server-flow.md`](docs/flows/server-flow.md) | -| Conversations | CRUD, branching, import/export | [`conversations-flow.md`](docs/flows/conversations-flow.md) | -| Database | IndexedDB schema, operations | [`database-flow.md`](docs/flows/database-flow.md) | -| Settings | Parameter sync, user overrides | [`settings-flow.md`](docs/flows/settings-flow.md) | - ---- - -## Architectural Patterns - -### 1. Reactive State with Svelte 5 Runes - -All stores use Svelte 5's fine-grained reactivity: - -```typescript -// Store with reactive state -class ChatStore { - #isLoading = $state(false); - #currentResponse = $state(''); - - // Derived values auto-update - get isStreaming() { - return $derived(this.#isLoading && this.#currentResponse.length > 0); - } -} - -// Exported reactive accessors -export const isLoading = () => chatStore.isLoading; -export const currentResponse = () => chatStore.currentResponse; -``` - -### 2. Unidirectional Data Flow - -Data flows in one direction, making state predictable: - -```mermaid -flowchart LR - subgraph UI["UI Layer"] - A[User Action] --> B[Component] - end - - subgraph State["State Layer"] - B --> C[Store Method] - C --> D[State Update] - end - - subgraph IO["I/O Layer"] - C --> E[Service] - E --> F[API / IndexedDB] - F -.->|Response| D - end - - D -->|Reactive| B -``` - -Components dispatch actions to stores, stores coordinate with services for I/O, and state updates reactively propagate back to the UI. - -### 3. Per-Conversation State - -Enables concurrent streaming across multiple conversations: - -```typescript -class ChatStore { - chatLoadingStates = new Map(); - chatStreamingStates = new Map(); - abortControllers = new Map(); -} -``` - -### 4. Message Branching with Tree Structure - -Conversations are stored as a tree, not a linear list: - -```typescript -interface DatabaseMessage { - id: string; - parent: string | null; // Points to parent message - children: string[]; // List of child message IDs - // ... -} - -interface DatabaseConversation { - currentNode: string; // Currently viewed branch tip - // ... -} -``` - -Navigation between branches updates `currentNode` without losing history. - -### 5. Layered Service Architecture - -Stores handle state; services handle I/O: - -```text -┌─────────────────┐ -│ Stores │ Business logic, state management -├─────────────────┤ -│ Services │ API calls, database operations -├─────────────────┤ -│ Storage/API │ IndexedDB, LocalStorage, HTTP -└─────────────────┘ -``` - -### 6. Server Role Abstraction - -Single codebase handles both MODEL and ROUTER modes: - -```typescript -// serverStore.ts -get isRouterMode() { - return this.role === ServerRole.ROUTER; -} - -// Components conditionally render based on mode -{#if isRouterMode()} - -{/if} -``` - -### 7. Modality Validation - -Prevents sending attachments to incompatible models: - -```typescript -// useModelChangeValidation hook -const validate = (modelId: string) => { - const modelModalities = modelsStore.getModelModalities(modelId); - const conversationModalities = conversationsStore.usedModalities; - - // Check if model supports all used modalities - if (conversationModalities.hasImages && !modelModalities.vision) { - return { valid: false, reason: 'Model does not support images' }; - } - // ... -}; -``` - -### 8. Persistent Storage Strategy - -Data is persisted across sessions using two storage mechanisms: - -```mermaid -flowchart TB - subgraph Browser["Browser Storage"] - subgraph IDB["IndexedDB (Dexie)"] - C[Conversations] - M[Messages] - end - subgraph LS["LocalStorage"] - S[Settings Config] - O[User Overrides] - T[Theme Preference] - end - end - - subgraph Stores["Svelte Stores"] - CS[conversationsStore] --> C - CS --> M - SS[settingsStore] --> S - SS --> O - SS --> T - end -``` - -- **IndexedDB**: Conversations and messages (large, structured data) -- **LocalStorage**: Settings, user parameter overrides, theme (small key-value data) -- **Memory only**: Server props, model list (fetched fresh on each session) - ---- - -## Testing - -### Test Types - -| Type | Tool | Location | Command | -| ------------- | ------------------ | ---------------- | ------------------- | -| **Unit** | Vitest | `tests/unit/` | `npm run test:unit` | -| **UI/Visual** | Storybook + Vitest | `tests/stories/` | `npm run test:ui` | -| **E2E** | Playwright | `tests/e2e/` | `npm run test:e2e` | -| **Client** | Vitest | `tests/client/`. | `npm run test:unit` | - -### Running Tests - -```bash -# All tests -npm run test - -# Individual test suites -npm run test:e2e # End-to-end (requires llama-server) -npm run test:client # Client-side unit tests -npm run test:server # Server-side unit tests -npm run test:ui # Storybook visual tests -``` - -### Storybook Development - -```bash -npm run storybook # Start Storybook dev server on :6006 -npm run build-storybook # Build static Storybook -``` - -### Linting and Formatting - -```bash -npm run lint # Check code style -npm run format # Auto-format with Prettier -npm run check # TypeScript type checking -``` - ---- - -## Project Structure - -```text -tools/server/webui/ -├── src/ -│ ├── lib/ -│ │ ├── components/ # UI components (app/, ui/) -│ │ ├── hooks/ # Svelte hooks -│ │ ├── stores/ # State management -│ │ ├── services/ # API and database services -│ │ ├── types/ # TypeScript interfaces -│ │ └── utils/ # Utility functions -│ ├── routes/ # SvelteKit routes -│ └── styles/ # Global styles -├── static/ # Static assets -├── tests/ # Test files -├── docs/ # Architecture diagrams -│ ├── architecture/ # High-level architecture -│ └── flows/ # Feature-specific flows -└── .storybook/ # Storybook configuration -``` - ---- - -## Related Documentation - -- [llama.cpp Server README](../README.md) - Full server documentation -- [Multimodal Documentation](../../../docs/multimodal.md) - Image and audio support -- [Function Calling](../../../docs/function-calling.md) - Tool use capabilities diff --git a/tools/server/webui/components.json b/tools/server/webui/components.json deleted file mode 100644 index 224bd70ac..000000000 --- a/tools/server/webui/components.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://shadcn-svelte.com/schema.json", - "tailwind": { - "css": "src/app.css", - "baseColor": "neutral" - }, - "aliases": { - "components": "$lib/components", - "utils": "$lib/components/ui/utils", - "ui": "$lib/components/ui", - "hooks": "$lib/hooks", - "lib": "$lib" - }, - "typescript": true, - "registry": "https://shadcn-svelte.com/registry" -} diff --git a/tools/server/webui/docs/architecture/high-level-architecture-simplified.md b/tools/server/webui/docs/architecture/high-level-architecture-simplified.md deleted file mode 100644 index 500f477c9..000000000 --- a/tools/server/webui/docs/architecture/high-level-architecture-simplified.md +++ /dev/null @@ -1,145 +0,0 @@ -```mermaid -flowchart TB - subgraph Routes["📍 Routes"] - R1["/ (Welcome)"] - R2["/chat/[id]"] - RL["+layout.svelte"] - end - - subgraph Components["🧩 Components"] - C_Sidebar["ChatSidebar"] - C_Screen["ChatScreen"] - C_Form["ChatForm"] - C_Messages["ChatMessages"] - C_Message["ChatMessage"] - C_ChatMessageAgenticContent["ChatMessageAgenticContent"] - C_MessageEditForm["ChatMessageEditForm"] - C_ModelsSelector["ModelsSelector"] - C_Settings["ChatSettings"] - C_McpSettings["McpServersSettings"] - C_McpResourceBrowser["McpResourceBrowser"] - C_McpServersSelector["McpServersSelector"] - end - - subgraph Hooks["🪝 Hooks"] - H1["useModelChangeValidation"] - H2["useProcessingState"] - end - - subgraph Stores["🗄️ Stores"] - S1["chatStore
Chat interactions & streaming"] - SA["agenticStore
Multi-turn agentic loop orchestration"] - S2["conversationsStore
Conversation data, messages & MCP overrides"] - S3["modelsStore
Model selection & loading"] - S4["serverStore
Server props & role detection"] - S5["settingsStore
User configuration incl. MCP"] - S6["mcpStore
MCP servers, tools, prompts"] - S7["mcpResourceStore
MCP resources & attachments"] - end - - subgraph Services["⚙️ Services"] - SV1["ChatService"] - SV2["ModelsService"] - SV3["PropsService"] - SV4["DatabaseService"] - SV5["ParameterSyncService"] - SV6["MCPService
protocol operations"] - end - - subgraph Storage["💾 Storage"] - ST1["IndexedDB
conversations, messages"] - ST2["LocalStorage
config, userOverrides, mcpServers"] - end - - subgraph APIs["🌐 llama-server API"] - API1["/v1/chat/completions"] - API2["/props"] - API3["/models/*"] - API4["/v1/models"] - end - - subgraph ExternalMCP["🔌 External MCP Servers"] - EXT1["MCP Server 1
WebSocket/HTTP/SSE"] - EXT2["MCP Server N"] - end - - %% Routes → Components - R1 & R2 --> C_Screen - RL --> C_Sidebar - - %% Layout runs MCP health checks - RL --> S6 - - %% Component hierarchy - C_Screen --> C_Form & C_Messages & C_Settings - C_Messages --> C_Message - C_Message --> C_ChatMessageAgenticContent - C_Message --> C_MessageEditForm - C_Form & C_MessageEditForm --> C_ModelsSelector - C_Form --> C_McpServersSelector - C_Settings --> C_McpSettings - C_McpSettings --> C_McpResourceBrowser - - %% Components → Hooks → Stores - C_Form & C_Messages --> H1 & H2 - H1 --> S3 & S4 - H2 --> S1 & S5 - - %% Components → Stores - C_Screen --> S1 & S2 - C_Sidebar --> S2 - C_ModelsSelector --> S3 & S4 - C_Settings --> S5 - C_McpSettings --> S6 - C_McpResourceBrowser --> S6 & S7 - C_McpServersSelector --> S6 - C_Form --> S6 - - %% chatStore → agenticStore → mcpStore (agentic loop) - S1 --> SA - SA --> SV1 - SA --> S6 - - %% Stores → Services - S1 --> SV1 & SV4 - S2 --> SV4 - S3 --> SV2 & SV3 - S4 --> SV3 - S5 --> SV5 - S6 --> SV6 - S7 --> SV6 - - %% Services → Storage - SV4 --> ST1 - SV5 --> ST2 - - %% Services → APIs - SV1 --> API1 - SV2 --> API3 & API4 - SV3 --> API2 - - %% MCP → External Servers - SV6 --> EXT1 & EXT2 - - %% Styling - classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px - classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px - classDef hookStyle fill:#fff8e1,stroke:#ff8f00,stroke-width:2px - classDef storeStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px - classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px - classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px - classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px - classDef mcpStyle fill:#e0f2f1,stroke:#00695c,stroke-width:2px - classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px - classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5 - - class R1,R2,RL routeStyle - class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_ChatMessageAgenticContent,C_MessageEditForm,C_ModelsSelector,C_Settings componentStyle - class C_McpSettings,C_McpResourceBrowser,C_McpServersSelector componentStyle - class H1,H2 hookStyle - class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle - class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle - class ST1,ST2 storageStyle - class API1,API2,API3,API4 apiStyle - class EXT1,EXT2 externalStyle -``` diff --git a/tools/server/webui/docs/architecture/high-level-architecture.md b/tools/server/webui/docs/architecture/high-level-architecture.md deleted file mode 100644 index 42ddb3f4f..000000000 --- a/tools/server/webui/docs/architecture/high-level-architecture.md +++ /dev/null @@ -1,373 +0,0 @@ -```mermaid -flowchart TB -subgraph Routes["📍 Routes"] -R1["/ (+page.svelte)"] -R2["/chat/[id]"] -RL["+layout.svelte"] -end - - subgraph Components["🧩 Components"] - direction TB - subgraph LayoutComponents["Layout"] - C_Sidebar["ChatSidebar"] - C_Screen["ChatScreen"] - end - subgraph ChatUIComponents["Chat UI"] - C_Form["ChatForm"] - C_Messages["ChatMessages"] - C_Message["ChatMessage"] - C_MessageUser["ChatMessageUser"] - C_MessageEditForm["ChatMessageEditForm"] - C_Attach["ChatAttachments"] - C_ModelsSelector["ModelsSelector"] - C_Settings["ChatSettings"] - end - subgraph MCPComponents["MCP UI"] - C_McpSettings["McpServersSettings"] - C_McpServerCard["McpServerCard"] - C_McpResourceBrowser["McpResourceBrowser"] - C_McpResourcePreview["McpResourcePreview"] - C_McpServersSelector["McpServersSelector"] - end - end - - subgraph Hooks["🪝 Hooks"] - H1["useModelChangeValidation"] - H2["useProcessingState"] - H3["isMobile"] - end - - subgraph Stores["🗄️ Stores"] - direction TB - subgraph S1["chatStore"] - S1State["State:
isLoading, currentResponse
errorDialogState
activeProcessingState
chatLoadingStates
chatStreamingStates
abortControllers
processingStates
activeConversationId
isStreamingActive"] - S1LoadState["Loading State:
setChatLoading()
isChatLoading()
syncLoadingStateForChat()
clearUIState()
isChatLoadingPublic()
getAllLoadingChats()
getAllStreamingChats()"] - S1ProcState["Processing State:
setActiveProcessingConversation()
getProcessingState()
clearProcessingState()
getActiveProcessingState()
updateProcessingStateFromTimings()
getCurrentProcessingStateSync()
restoreProcessingStateFromMessages()"] - S1Stream["Streaming:
streamChatCompletion()
startStreaming()
stopStreaming()
stopGeneration()
isStreaming()"] - S1Error["Error Handling:
showErrorDialog()
dismissErrorDialog()
isAbortError()"] - S1Msg["Message Operations:
addMessage()
sendMessage()
updateMessage()
deleteMessage()
getDeletionInfo()"] - S1Regen["Regeneration:
regenerateMessage()
regenerateMessageWithBranching()
continueAssistantMessage()"] - S1Edit["Editing:
editAssistantMessage()
editUserMessagePreserveResponses()
editMessageWithBranching()
clearEditMode()
isEditModeActive()
getAddFilesHandler()
setEditModeActive()"] - S1Utils["Utilities:
getApiOptions()
parseTimingData()
getOrCreateAbortController()
getConversationModel()"] - end - subgraph SA["agenticStore"] - SAState["State:
sessions (Map)
isAnyRunning"] - SASession["Session Management:
getSession()
updateSession()
clearSession()
getActiveSessions()
isRunning()
currentTurn()
totalToolCalls()
lastError()
streamingToolCall()"] - SAConfig["Configuration:
getConfig()
maxTurns, maxToolPreviewLines"] - SAFlow["Agentic Loop:
runAgenticFlow()
executeAgenticLoop()
normalizeToolCalls()
emitToolCallResult()
extractBase64Attachments()"] - end - subgraph S2["conversationsStore"] - S2State["State:
conversations
activeConversation
activeMessages
isInitialized
pendingMcpServerOverrides
titleUpdateConfirmationCallback"] - S2Lifecycle["Lifecycle:
initialize()
loadConversations()
clearActiveConversation()"] - S2ConvCRUD["Conversation CRUD:
createConversation()
loadConversation()
deleteConversation()
deleteAll()
updateConversationName()
updateConversationTitleWithConfirmation()"] - S2MsgMgmt["Message Management:
refreshActiveMessages()
addMessageToActive()
updateMessageAtIndex()
findMessageIndex()
sliceActiveMessages()
removeMessageAtIndex()
getConversationMessages()"] - S2Nav["Navigation:
navigateToSibling()
updateCurrentNode()
updateConversationTimestamp()"] - S2McpOverrides["MCP Per-Chat Overrides:
getMcpServerOverride()
getAllMcpServerOverrides()
setMcpServerOverride()
toggleMcpServerForChat()
removeMcpServerOverride()
isMcpServerEnabledForChat()
clearPendingMcpServerOverrides()"] - S2Export["Import/Export:
downloadConversation()
exportAllConversations()
importConversations()
importConversationsData()
triggerDownload()"] - S2Utils["Utilities:
setTitleUpdateConfirmationCallback()"] - end - subgraph S3["modelsStore"] - S3State["State:
models, routerModels
selectedModelId
selectedModelName
loading, updating, error
modelLoadingStates
modelPropsCache
modelPropsFetching
propsCacheVersion"] - S3Getters["Computed Getters:
selectedModel
loadedModelIds
loadingModelIds
singleModelName"] - S3Modal["Modalities:
getModelModalities()
modelSupportsVision()
modelSupportsAudio()
getModelModalitiesArray()
getModelProps()
updateModelModalities()"] - S3Status["Status Queries:
isModelLoaded()
isModelOperationInProgress()
getModelStatus()
isModelPropsFetching()"] - S3Fetch["Data Fetching:
fetch()
fetchRouterModels()
fetchModelProps()
fetchModalitiesForLoadedModels()"] - S3Select["Model Selection:
selectModelById()
selectModelByName()
clearSelection()
findModelByName()
findModelById()
hasModel()"] - S3LoadUnload["Loading/Unloading Models:
loadModel()
unloadModel()
ensureModelLoaded()
waitForModelStatus()
pollForModelStatus()"] - S3Utils["Utilities:
toDisplayName()
clear()"] - end - subgraph S4["serverStore"] - S4State["State:
props
loading, error
role
fetchPromise"] - S4Getters["Getters:
defaultParams
contextSize
isRouterMode
isModelMode"] - S4Data["Data Handling:
fetch()
getErrorMessage()
clear()"] - S4Utils["Utilities:
detectRole()"] - end - subgraph S5["settingsStore"] - S5State["State:
config
theme
isInitialized
userOverrides"] - S5Lifecycle["Lifecycle:
initialize()
loadConfig()
saveConfig()
loadTheme()
saveTheme()"] - S5Update["Config Updates:
updateConfig()
updateMultipleConfig()
updateTheme()"] - S5Reset["Reset:
resetConfig()
resetTheme()
resetAll()
resetParameterToServerDefault()"] - S5Sync["Server Sync:
syncWithServerDefaults()
forceSyncWithServerDefaults()"] - S5Utils["Utilities:
getConfig()
getAllConfig()
getParameterInfo()
getParameterDiff()
getServerDefaults()
clearAllUserOverrides()"] - end - subgraph S6["mcpStore"] - S6State["State:
isInitializing, error
toolCount, connectedServers
healthChecks (Map)
connections (Map)
toolsIndex (Map)"] - S6Lifecycle["Lifecycle:
ensureInitialized()
initialize()
shutdown()
acquireConnection()
releaseConnection()"] - S6Health["Health Checks:
runHealthCheck()
runHealthChecksForServers()
updateHealthCheck()
getHealthCheckState()
clearHealthCheck()"] - S6Servers["Server Management:
getServers()
addServer()
updateServer()
removeServer()
getServerById()
getServerDisplayName()"] - S6Tools["Tool Operations:
getToolDefinitionsForLLM()
getToolNames()
hasTool()
getToolServer()
executeTool()
executeToolByName()"] - S6Prompts["Prompt Operations:
getAllPrompts()
getPrompt()
hasPromptsCapability()
getPromptCompletions()"] - end - subgraph S7["mcpResourceStore"] - S7State["State:
serverResources (Map)
cachedResources (Map)
subscriptions (Map)
attachments[]
isLoading"] - S7Resources["Resource Discovery:
setServerResources()
getServerResources()
getAllResourceInfos()
getAllTemplateInfos()
clearServerResources()"] - S7Cache["Caching:
cacheResourceContent()
getCachedContent()
invalidateCache()
clearCache()"] - S7Subs["Subscriptions:
addSubscription()
removeSubscription()
isSubscribed()
handleResourceUpdate()"] - S7Attach["Attachments:
addAttachment()
updateAttachmentContent()
removeAttachment()
clearAttachments()
toMessageExtras()"] - end - - subgraph ReactiveExports["⚡ Reactive Exports"] - direction LR - subgraph ChatExports["chatStore"] - RE1["isLoading()"] - RE2["currentResponse()"] - RE3["errorDialog()"] - RE4["activeProcessingState()"] - RE5["isChatStreaming()"] - RE6["isChatLoading()"] - RE7["getChatStreaming()"] - RE8["getAllLoadingChats()"] - RE9["getAllStreamingChats()"] - RE9a["isEditModeActive()"] - RE9b["getAddFilesHandler()"] - RE9c["setEditModeActive()"] - RE9d["clearEditMode()"] - end - subgraph AgenticExports["agenticStore"] - REA1["agenticIsRunning()"] - REA2["agenticCurrentTurn()"] - REA3["agenticTotalToolCalls()"] - REA4["agenticLastError()"] - REA5["agenticStreamingToolCall()"] - REA6["agenticIsAnyRunning()"] - end - subgraph ConvExports["conversationsStore"] - RE10["conversations()"] - RE11["activeConversation()"] - RE12["activeMessages()"] - RE13["isConversationsInitialized()"] - end - subgraph ModelsExports["modelsStore"] - RE15["modelOptions()"] - RE16["routerModels()"] - RE17["modelsLoading()"] - RE18["modelsUpdating()"] - RE19["modelsError()"] - RE20["selectedModelId()"] - RE21["selectedModelName()"] - RE22["selectedModelOption()"] - RE23["loadedModelIds()"] - RE24["loadingModelIds()"] - RE25["propsCacheVersion()"] - RE26["singleModelName()"] - end - subgraph ServerExports["serverStore"] - RE27["serverProps()"] - RE28["serverLoading()"] - RE29["serverError()"] - RE30["serverRole()"] - RE31["defaultParams()"] - RE32["contextSize()"] - RE33["isRouterMode()"] - RE34["isModelMode()"] - end - subgraph SettingsExports["settingsStore"] - RE35["config()"] - RE36["theme()"] - RE37["isInitialized()"] - end - subgraph MCPExports["mcpStore / mcpResourceStore"] - RE38["mcpResources()"] - RE39["mcpResourceAttachments()"] - RE40["mcpHasResourceAttachments()"] - RE41["mcpTotalResourceCount()"] - RE42["mcpResourcesLoading()"] - end - end - end - - subgraph Services["⚙️ Services"] - direction TB - subgraph SV1["ChatService"] - SV1Msg["Messaging:
sendMessage()"] - SV1Stream["Streaming:
handleStreamResponse()
handleNonStreamResponse()"] - SV1Convert["Conversion:
convertDbMessageToApiChatMessageData()
mergeToolCallDeltas()"] - SV1Utils["Utilities:
stripReasoningContent()
extractModelName()
parseErrorResponse()"] - end - subgraph SV2["ModelsService"] - SV2List["Listing:
list()
listRouter()"] - SV2LoadUnload["Load/Unload:
load()
unload()"] - SV2Status["Status:
isModelLoaded()
isModelLoading()"] - end - subgraph SV3["PropsService"] - SV3Fetch["Fetching:
fetch()
fetchForModel()"] - end - subgraph SV4["DatabaseService"] - SV4Conv["Conversations:
createConversation()
getConversation()
getAllConversations()
updateConversation()
deleteConversation()"] - SV4Msg["Messages:
createMessageBranch()
createRootMessage()
createSystemMessage()
getConversationMessages()
updateMessage()
deleteMessage()
deleteMessageCascading()"] - SV4Node["Navigation:
updateCurrentNode()"] - SV4Import["Import:
importConversations()"] - end - subgraph SV5["ParameterSyncService"] - SV5Extract["Extraction:
extractServerDefaults()"] - SV5Merge["Merging:
mergeWithServerDefaults()"] - SV5Info["Info:
getParameterInfo()
canSyncParameter()
getSyncableParameterKeys()
validateServerParameter()"] - SV5Diff["Diff:
createParameterDiff()"] - end - subgraph SV6["MCPService"] - SV6Transport["Transport:
createTransport()
WebSocket / StreamableHTTP / SSE"] - SV6Conn["Connection:
connect()
disconnect()"] - SV6Tools["Tools:
listTools()
callTool()"] - SV6Prompts["Prompts:
listPrompts()
getPrompt()"] - SV6Resources["Resources:
listResources()
listResourceTemplates()
readResource()
subscribeResource()
unsubscribeResource()"] - SV6Complete["Completions:
complete()"] - end - end - - subgraph ExternalMCP["🔌 External MCP Servers"] - EXT1["MCP Server 1
(WebSocket/StreamableHTTP/SSE)"] - EXT2["MCP Server N"] - end - - subgraph Storage["💾 Storage"] - ST1["IndexedDB"] - ST2["conversations"] - ST3["messages"] - ST5["LocalStorage"] - ST6["config"] - ST7["userOverrides"] - ST8["mcpServers"] - end - - subgraph APIs["🌐 llama-server API"] - API1["/v1/chat/completions"] - API2["/props
/props?model="] - API3["/models
/models/load
/models/unload"] - API4["/v1/models"] - end - - %% Routes render Components - R1 --> C_Screen - R2 --> C_Screen - RL --> C_Sidebar - - %% Layout runs MCP health checks on startup - RL --> S6 - - %% Component hierarchy - C_Screen --> C_Form & C_Messages & C_Settings - C_Messages --> C_Message - C_Message --> C_MessageUser - C_MessageUser --> C_MessageEditForm - C_MessageEditForm --> C_ModelsSelector - C_MessageEditForm --> C_Attach - C_Form --> C_ModelsSelector - C_Form --> C_Attach - C_Form --> C_McpServersSelector - C_Message --> C_Attach - - %% MCP Components hierarchy - C_Settings --> C_McpSettings - C_McpSettings --> C_McpServerCard - C_McpServerCard --> C_McpResourceBrowser - C_McpResourceBrowser --> C_McpResourcePreview - - %% Components use Hooks - C_Form --> H1 - C_Message --> H1 & H2 - C_MessageEditForm --> H1 - C_Screen --> H2 - - %% Hooks use Stores - H1 --> S3 & S4 - H2 --> S1 & S5 - - %% Components use Stores - C_Screen --> S1 & S2 - C_Messages --> S2 - C_Message --> S1 & S2 & S3 - C_Form --> S1 & S3 & S6 - C_Sidebar --> S2 - C_ModelsSelector --> S3 & S4 - C_Settings --> S5 - C_McpSettings --> S6 - C_McpServerCard --> S6 - C_McpResourceBrowser --> S6 & S7 - C_McpServersSelector --> S6 - - %% Stores export Reactive State - S1 -. exports .-> ChatExports - SA -. exports .-> AgenticExports - S2 -. exports .-> ConvExports - S3 -. exports .-> ModelsExports - S4 -. exports .-> ServerExports - S5 -. exports .-> SettingsExports - S6 -. exports .-> MCPExports - S7 -. exports .-> MCPExports - - %% chatStore → agenticStore (agentic loop orchestration) - S1 --> SA - SA --> SV1 - SA --> S6 - - %% Stores use Services - S1 --> SV1 & SV4 - S2 --> SV4 - S3 --> SV2 & SV3 - S4 --> SV3 - S5 --> SV5 - S6 --> SV6 - S7 --> SV6 - - %% Services to Storage - SV4 --> ST1 - ST1 --> ST2 & ST3 - SV5 --> ST5 - ST5 --> ST6 & ST7 & ST8 - - %% Services to APIs - SV1 --> API1 - SV2 --> API3 & API4 - SV3 --> API2 - - %% MCP → External Servers - SV6 --> EXT1 & EXT2 - - %% Styling - classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px - classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px - classDef componentGroupStyle fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px - classDef hookStyle fill:#fff8e1,stroke:#ff8f00,stroke-width:2px - classDef storeStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px - classDef stateStyle fill:#ffe0b2,stroke:#e65100,stroke-width:1px - classDef methodStyle fill:#ffecb3,stroke:#e65100,stroke-width:1px - classDef reactiveStyle fill:#fffde7,stroke:#f9a825,stroke-width:1px - classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px - classDef serviceMStyle fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px - classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5 - classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px - classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px - - class R1,R2,RL routeStyle - class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageUser,C_MessageEditForm componentStyle - class C_ModelsSelector,C_Settings componentStyle - class C_Attach componentStyle - class C_McpSettings,C_McpServerCard,C_McpResourceBrowser,C_McpResourcePreview,C_McpServersSelector componentStyle - class H1,H2,H3 hookStyle - class LayoutComponents,ChatUIComponents,MCPComponents componentGroupStyle - class Hooks hookStyle - classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px - classDef agenticMethodStyle fill:#c5cae9,stroke:#283593,stroke-width:1px - - class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle - class S1State,S2State,S3State,S4State,S5State,SAState,S6State,S7State stateStyle - class S1Msg,S1Regen,S1Edit,S1Stream,S1LoadState,S1ProcState,S1Error,S1Utils methodStyle - class SASession,SAConfig,SAFlow methodStyle - class S2Lifecycle,S2ConvCRUD,S2MsgMgmt,S2Nav,S2McpOverrides,S2Export,S2Utils methodStyle - class S3Getters,S3Modal,S3Status,S3Fetch,S3Select,S3LoadUnload,S3Utils methodStyle - class S4Getters,S4Data,S4Utils methodStyle - class S5Lifecycle,S5Update,S5Reset,S5Sync,S5Utils methodStyle - class S6Lifecycle,S6Health,S6Servers,S6Tools,S6Prompts methodStyle - class S7Resources,S7Cache,S7Subs,S7Attach methodStyle - class ChatExports,AgenticExports,ConvExports,ModelsExports,ServerExports,SettingsExports,MCPExports reactiveStyle - class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle - class SV6Transport,SV6Conn,SV6Tools,SV6Prompts,SV6Resources,SV6Complete serviceMStyle - class EXT1,EXT2 externalStyle - class SV1Msg,SV1Stream,SV1Convert,SV1Utils serviceMStyle - class SV2List,SV2LoadUnload,SV2Status serviceMStyle - class SV3Fetch serviceMStyle - class SV4Conv,SV4Msg,SV4Node,SV4Import serviceMStyle - class SV5Extract,SV5Merge,SV5Info,SV5Diff serviceMStyle - class ST1,ST2,ST3,ST5,ST6,ST7,ST8 storageStyle - class API1,API2,API3,API4 apiStyle -``` diff --git a/tools/server/webui/docs/flows/chat-flow.md b/tools/server/webui/docs/flows/chat-flow.md deleted file mode 100644 index 296693c6a..000000000 --- a/tools/server/webui/docs/flows/chat-flow.md +++ /dev/null @@ -1,228 +0,0 @@ -```mermaid -sequenceDiagram - participant UI as 🧩 ChatForm / ChatMessage - participant chatStore as 🗄️ chatStore - participant agenticStore as 🗄️ agenticStore - participant convStore as 🗄️ conversationsStore - participant settingsStore as 🗄️ settingsStore - participant mcpStore as 🗄️ mcpStore - participant ChatSvc as ⚙️ ChatService - participant DbSvc as ⚙️ DatabaseService - participant API as 🌐 /v1/chat/completions - - Note over chatStore: State:
isLoading, currentResponse
errorDialogState, activeProcessingState
chatLoadingStates (Map)
chatStreamingStates (Map)
abortControllers (Map)
processingStates (Map) - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: 💬 SEND MESSAGE - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>chatStore: sendMessage(content, extras) - activate chatStore - - chatStore->>chatStore: setChatLoading(convId, true) - chatStore->>chatStore: clearChatStreaming(convId) - - alt no active conversation - chatStore->>convStore: createConversation() - Note over convStore: → see conversations-flow.mmd - end - - chatStore->>mcpStore: consumeResourceAttachmentsAsExtras() - Note right of mcpStore: Converts pending MCP resource
attachments into message extras - - chatStore->>chatStore: addMessage("user", content, extras) - chatStore->>DbSvc: createMessageBranch(userMsg, parentId) - chatStore->>convStore: addMessageToActive(userMsg) - chatStore->>convStore: updateCurrentNode(userMsg.id) - - chatStore->>chatStore: createAssistantMessage(userMsg.id) - chatStore->>DbSvc: createMessageBranch(assistantMsg, userMsg.id) - chatStore->>convStore: addMessageToActive(assistantMsg) - - chatStore->>chatStore: streamChatCompletion(messages, assistantMsg) - deactivate chatStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: 🌊 STREAMING (with agentic flow detection) - %% ═══════════════════════════════════════════════════════════════════════════ - - activate chatStore - chatStore->>chatStore: startStreaming() - Note right of chatStore: isStreamingActive = true - - chatStore->>chatStore: setActiveProcessingConversation(convId) - chatStore->>chatStore: getOrCreateAbortController(convId) - Note right of chatStore: abortControllers.set(convId, new AbortController()) - - chatStore->>chatStore: getApiOptions() - Note right of chatStore: Merge from settingsStore.config:
temperature, max_tokens, top_p, etc. - - alt agenticConfig.enabled && mcpStore has connected servers - chatStore->>agenticStore: runAgenticFlow(convId, messages, assistantMsg, options, signal) - Note over agenticStore: Multi-turn agentic loop:
1. Call ChatService.sendMessage()
2. If response has tool_calls → execute via mcpStore
3. Append tool results as messages
4. Loop until no more tool_calls or maxTurns
→ see agentic flow details below - agenticStore-->>chatStore: final response with timings - else standard (non-agentic) flow - chatStore->>ChatSvc: sendMessage(messages, options, signal) - end - - activate ChatSvc - - ChatSvc->>ChatSvc: convertDbMessageToApiChatMessageData(messages) - Note right of ChatSvc: DatabaseMessage[] → ApiChatMessageData[]
Process attachments (images, PDFs, audio) - - ChatSvc->>API: POST /v1/chat/completions - Note right of API: {messages, model?, stream: true, ...params} - - loop SSE chunks - API-->>ChatSvc: data: {"choices":[{"delta":{...}}]} - ChatSvc->>ChatSvc: handleStreamResponse(response) - - alt content chunk - ChatSvc-->>chatStore: onChunk(content) - chatStore->>chatStore: setChatStreaming(convId, response, msgId) - Note right of chatStore: currentResponse = $state(accumulated) - chatStore->>convStore: updateMessageAtIndex(idx, {content}) - end - - alt reasoning chunk - ChatSvc-->>chatStore: onReasoningChunk(reasoning) - chatStore->>convStore: updateMessageAtIndex(idx, {thinking}) - end - - alt tool_calls chunk - ChatSvc-->>chatStore: onToolCallChunk(toolCalls) - chatStore->>convStore: updateMessageAtIndex(idx, {toolCalls}) - end - - alt model info - ChatSvc-->>chatStore: onModel(modelName) - chatStore->>chatStore: recordModel(modelName) - chatStore->>DbSvc: updateMessage(msgId, {model}) - end - - alt timings (during stream) - ChatSvc-->>chatStore: onTimings(timings, promptProgress) - chatStore->>chatStore: updateProcessingStateFromTimings() - end - - chatStore-->>UI: reactive $state update - end - - API-->>ChatSvc: data: [DONE] - ChatSvc-->>chatStore: onComplete(content, reasoning, timings, toolCalls) - deactivate ChatSvc - - chatStore->>chatStore: stopStreaming() - chatStore->>DbSvc: updateMessage(msgId, {content, timings, model}) - chatStore->>convStore: updateCurrentNode(msgId) - chatStore->>chatStore: setChatLoading(convId, false) - chatStore->>chatStore: clearChatStreaming(convId) - chatStore->>chatStore: clearProcessingState(convId) - deactivate chatStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: ⏹️ STOP GENERATION - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>chatStore: stopGeneration() - activate chatStore - chatStore->>chatStore: savePartialResponseIfNeeded(convId) - Note right of chatStore: Save currentResponse to DB if non-empty - chatStore->>chatStore: abortControllers.get(convId).abort() - Note right of chatStore: fetch throws AbortError → caught by isAbortError() - chatStore->>chatStore: stopStreaming() - chatStore->>chatStore: setChatLoading(convId, false) - chatStore->>chatStore: clearChatStreaming(convId) - chatStore->>chatStore: clearProcessingState(convId) - deactivate chatStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: 🔁 REGENERATE - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>chatStore: regenerateMessageWithBranching(msgId, model?) - activate chatStore - chatStore->>convStore: findMessageIndex(msgId) - chatStore->>chatStore: Get parent of target message - chatStore->>chatStore: createAssistantMessage(parentId) - chatStore->>DbSvc: createMessageBranch(newAssistantMsg, parentId) - chatStore->>convStore: refreshActiveMessages() - Note right of chatStore: Same streaming flow - chatStore->>chatStore: streamChatCompletion(...) - deactivate chatStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: ➡️ CONTINUE - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>chatStore: continueAssistantMessage(msgId) - activate chatStore - chatStore->>chatStore: Get existing content from message - chatStore->>chatStore: streamChatCompletion(..., existingContent) - Note right of chatStore: Appends to existing message content - deactivate chatStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: ✏️ EDIT USER MESSAGE - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>chatStore: editMessageWithBranching(msgId, newContent, extras) - activate chatStore - chatStore->>chatStore: Get parent of target message - chatStore->>DbSvc: createMessageBranch(editedMsg, parentId) - chatStore->>convStore: refreshActiveMessages() - Note right of chatStore: Creates new branch, original preserved - chatStore->>chatStore: createAssistantMessage(editedMsg.id) - chatStore->>chatStore: streamChatCompletion(...) - Note right of chatStore: Automatically regenerates response - deactivate chatStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: ❌ ERROR HANDLING - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over chatStore: On stream error (non-abort): - chatStore->>chatStore: showErrorDialog(type, message) - Note right of chatStore: errorDialogState = {type: 'timeout'|'server', message} - chatStore->>convStore: removeMessageAtIndex(failedMsgIdx) - chatStore->>DbSvc: deleteMessage(failedMsgId) - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: 🤖 AGENTIC LOOP (when agenticConfig.enabled) - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over agenticStore: agenticStore.runAgenticFlow(convId, messages, assistantMsg, options, signal) - activate agenticStore - agenticStore->>agenticStore: getSession(convId) or create new - agenticStore->>agenticStore: updateSession(turn: 0, running: true) - - loop executeAgenticLoop (until no tool_calls or maxTurns) - agenticStore->>agenticStore: turn++ - agenticStore->>ChatSvc: sendMessage(messages, options, signal) - ChatSvc->>API: POST /v1/chat/completions - API-->>ChatSvc: response with potential tool_calls - ChatSvc-->>agenticStore: onComplete(content, reasoning, timings, toolCalls) - - alt response has tool_calls - agenticStore->>agenticStore: normalizeToolCalls(toolCalls) - loop for each tool_call - agenticStore->>agenticStore: updateSession(streamingToolCall) - agenticStore->>mcpStore: executeTool(mcpCall, signal) - mcpStore-->>agenticStore: tool result - agenticStore->>agenticStore: extractBase64Attachments(result) - agenticStore->>agenticStore: emitToolCallResult(convId, ...) - agenticStore->>convStore: addMessageToActive(toolResultMsg) - agenticStore->>DbSvc: createMessageBranch(toolResultMsg) - end - agenticStore->>agenticStore: Create new assistantMsg for next turn - Note right of agenticStore: Continue loop with updated messages - else no tool_calls (final response) - agenticStore->>agenticStore: buildFinalTimings(allTurns) - Note right of agenticStore: Break loop, return final response - end - end - - agenticStore->>agenticStore: updateSession(running: false) - agenticStore-->>chatStore: final content, timings, model - deactivate agenticStore -``` diff --git a/tools/server/webui/docs/flows/conversations-flow.md b/tools/server/webui/docs/flows/conversations-flow.md deleted file mode 100644 index bd2309bc0..000000000 --- a/tools/server/webui/docs/flows/conversations-flow.md +++ /dev/null @@ -1,183 +0,0 @@ -```mermaid -sequenceDiagram - participant UI as 🧩 ChatSidebar / ChatScreen - participant convStore as 🗄️ conversationsStore - participant chatStore as 🗄️ chatStore - participant DbSvc as ⚙️ DatabaseService - participant IDB as 💾 IndexedDB - - Note over convStore: State:
conversations: DatabaseConversation[]
activeConversation: DatabaseConversation | null
activeMessages: DatabaseMessage[]
isInitialized: boolean
pendingMcpServerOverrides: Map<string, McpServerOverride> - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,IDB: 🚀 INITIALIZATION - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over convStore: Auto-initialized in constructor (browser only) - convStore->>convStore: initialize() - activate convStore - convStore->>convStore: loadConversations() - convStore->>DbSvc: getAllConversations() - DbSvc->>IDB: SELECT * FROM conversations ORDER BY lastModified DESC - IDB-->>DbSvc: Conversation[] - DbSvc-->>convStore: conversations - convStore->>convStore: conversations = $state(data) - convStore->>convStore: isInitialized = true - deactivate convStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,IDB: ➕ CREATE CONVERSATION - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>convStore: createConversation(name?) - activate convStore - convStore->>DbSvc: createConversation(name || "New Chat") - DbSvc->>IDB: INSERT INTO conversations - IDB-->>DbSvc: conversation {id, name, lastModified, currNode: ""} - DbSvc-->>convStore: conversation - convStore->>convStore: conversations.unshift(conversation) - convStore->>convStore: activeConversation = $state(conversation) - convStore->>convStore: activeMessages = $state([]) - - alt pendingMcpServerOverrides has entries - loop each pending override - convStore->>DbSvc: Store MCP server override for new conversation - end - convStore->>convStore: clearPendingMcpServerOverrides() - end - deactivate convStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,IDB: 📂 LOAD CONVERSATION - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>convStore: loadConversation(convId) - activate convStore - convStore->>DbSvc: getConversation(convId) - DbSvc->>IDB: SELECT * FROM conversations WHERE id = ? - IDB-->>DbSvc: conversation - convStore->>convStore: activeConversation = $state(conversation) - - convStore->>convStore: refreshActiveMessages() - convStore->>DbSvc: getConversationMessages(convId) - DbSvc->>IDB: SELECT * FROM messages WHERE convId = ? - IDB-->>DbSvc: allMessages[] - convStore->>convStore: filterByLeafNodeId(allMessages, currNode) - Note right of convStore: Filter to show only current branch path - convStore->>convStore: activeMessages = $state(filtered) - - Note right of convStore: Route (+page.svelte) then calls:
chatStore.syncLoadingStateForChat(convId) - deactivate convStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,IDB: 🌳 MESSAGE BRANCHING MODEL - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over IDB: Message Tree Structure:
- Each message has parent (null for root)
- Each message has children[] array
- Conversation.currNode points to active leaf
- filterByLeafNodeId() traverses from root to currNode - - rect rgb(240, 240, 255) - Note over convStore: Example Branch Structure: - Note over convStore: root → user1 → assistant1 → user2 → assistant2a (currNode)
↘ assistant2b (alt branch) - end - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,IDB: ↔️ BRANCH NAVIGATION - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>convStore: navigateToSibling(msgId, direction) - activate convStore - convStore->>convStore: Find message in activeMessages - convStore->>convStore: Get parent message - convStore->>convStore: Find sibling in parent.children[] - convStore->>convStore: findLeafNode(siblingId, allMessages) - Note right of convStore: Navigate to leaf of sibling branch - convStore->>convStore: updateCurrentNode(leafId) - convStore->>DbSvc: updateCurrentNode(convId, leafId) - DbSvc->>IDB: UPDATE conversations SET currNode = ? - convStore->>convStore: refreshActiveMessages() - deactivate convStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,IDB: 📝 UPDATE CONVERSATION - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>convStore: updateConversationName(convId, newName) - activate convStore - convStore->>DbSvc: updateConversation(convId, {name: newName}) - DbSvc->>IDB: UPDATE conversations SET name = ? - convStore->>convStore: Update in conversations array - deactivate convStore - - Note over convStore: Auto-title update (after first response): - convStore->>convStore: updateConversationTitleWithConfirmation() - convStore->>convStore: titleUpdateConfirmationCallback?() - Note right of convStore: Shows dialog if title would change - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,IDB: 🗑️ DELETE CONVERSATION - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>convStore: deleteConversation(convId) - activate convStore - convStore->>DbSvc: deleteConversation(convId) - DbSvc->>IDB: DELETE FROM conversations WHERE id = ? - DbSvc->>IDB: DELETE FROM messages WHERE convId = ? - convStore->>convStore: conversations.filter(c => c.id !== convId) - alt deleted active conversation - convStore->>convStore: clearActiveConversation() - end - deactivate convStore - - UI->>convStore: deleteAll() - activate convStore - convStore->>DbSvc: Delete all conversations and messages - convStore->>convStore: conversations = [] - convStore->>convStore: clearActiveConversation() - deactivate convStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,IDB: � MCP SERVER PER-CHAT OVERRIDES - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over convStore: Conversations can override which MCP servers are enabled. - Note over convStore: Uses pendingMcpServerOverrides before conversation
is created, then persists to conversation metadata. - - UI->>convStore: setMcpServerOverride(convId, serverName, override) - Note right of convStore: override = {enabled: boolean} - - UI->>convStore: toggleMcpServerForChat(convId, serverName, enabled) - activate convStore - convStore->>convStore: setMcpServerOverride(convId, serverName, {enabled}) - deactivate convStore - - UI->>convStore: isMcpServerEnabledForChat(convId, serverName) - Note right of convStore: Check override → fall back to global MCP config - - UI->>convStore: getAllMcpServerOverrides(convId) - Note right of convStore: Returns all overrides for a conversation - - UI->>convStore: removeMcpServerOverride(convId, serverName) - UI->>convStore: getMcpServerOverride(convId, serverName) - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,IDB: 📤 EXPORT / 📥 IMPORT - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>convStore: exportAllConversations() - activate convStore - convStore->>DbSvc: getAllConversations() - loop each conversation - convStore->>DbSvc: getConversationMessages(convId) - end - convStore->>convStore: triggerDownload(JSON blob) - deactivate convStore - - UI->>convStore: importConversations(file) - activate convStore - convStore->>convStore: Parse JSON file - convStore->>convStore: importConversationsData(parsed) - convStore->>DbSvc: importConversations(parsed) - Note right of DbSvc: Skips duplicate conversations
(checks existing by ID) - DbSvc->>IDB: INSERT conversations + messages (skip existing) - convStore->>convStore: loadConversations() - deactivate convStore -``` diff --git a/tools/server/webui/docs/flows/data-flow-simplified-model-mode.md b/tools/server/webui/docs/flows/data-flow-simplified-model-mode.md deleted file mode 100644 index 07b362147..000000000 --- a/tools/server/webui/docs/flows/data-flow-simplified-model-mode.md +++ /dev/null @@ -1,45 +0,0 @@ -```mermaid -%% MODEL Mode Data Flow (single model) -%% Detailed flows: ./flows/server-flow.mmd, ./flows/models-flow.mmd, ./flows/chat-flow.mmd - -sequenceDiagram - participant User as 👤 User - participant UI as 🧩 UI - participant Stores as 🗄️ Stores - participant DB as 💾 IndexedDB - participant API as 🌐 llama-server - - Note over User,API: 🚀 Initialization (see: server-flow.mmd, models-flow.mmd) - - UI->>Stores: initialize() - Stores->>DB: load conversations - Stores->>API: GET /props - API-->>Stores: server config + modalities - Stores->>API: GET /v1/models - API-->>Stores: single model (auto-selected) - - Note over User,API: 💬 Chat Flow (see: chat-flow.mmd) - - User->>UI: send message - UI->>Stores: sendMessage() - Stores->>DB: save user message - Stores->>API: POST /v1/chat/completions (stream) - loop streaming - API-->>Stores: SSE chunks - Stores-->>UI: reactive update - end - API-->>Stores: done + timings - Stores->>DB: save assistant message - - Note over User,API: 🔁 Regenerate - - User->>UI: regenerate - Stores->>DB: create message branch - Note right of Stores: same streaming flow - - Note over User,API: ⏹️ Stop - - User->>UI: stop - Stores->>Stores: abort stream - Stores->>DB: save partial response -``` diff --git a/tools/server/webui/docs/flows/data-flow-simplified-router-mode.md b/tools/server/webui/docs/flows/data-flow-simplified-router-mode.md deleted file mode 100644 index bccacf568..000000000 --- a/tools/server/webui/docs/flows/data-flow-simplified-router-mode.md +++ /dev/null @@ -1,77 +0,0 @@ -```mermaid -%% ROUTER Mode Data Flow (multi-model) -%% Detailed flows: ./flows/server-flow.mmd, ./flows/models-flow.mmd, ./flows/chat-flow.mmd - -sequenceDiagram - participant User as 👤 User - participant UI as 🧩 UI - participant Stores as 🗄️ Stores - participant DB as 💾 IndexedDB - participant API as 🌐 llama-server - - Note over User,API: 🚀 Initialization (see: server-flow.mmd, models-flow.mmd) - - UI->>Stores: initialize() - Stores->>DB: load conversations - Stores->>API: GET /props - API-->>Stores: {role: "router"} - Stores->>API: GET /v1/models - API-->>Stores: models[] with status (loaded/available) - loop each loaded model - Stores->>API: GET /props?model=X - API-->>Stores: modalities (vision/audio) - end - - Note over User,API: 🔄 Model Selection (see: models-flow.mmd) - - User->>UI: select model - alt model not loaded - Stores->>API: POST /models/load - loop poll status - Stores->>API: GET /v1/models - API-->>Stores: check if loaded - end - Stores->>API: GET /props?model=X - API-->>Stores: cache modalities - end - Stores->>Stores: validate modalities vs conversation - alt valid - Stores->>Stores: select model - else invalid - Stores->>API: POST /models/unload - UI->>User: show error toast - end - - Note over User,API: 💬 Chat Flow (see: chat-flow.mmd) - - User->>UI: send message - UI->>Stores: sendMessage() - Stores->>DB: save user message - Stores->>API: POST /v1/chat/completions {model: X} - Note right of API: router forwards to model - loop streaming - API-->>Stores: SSE chunks + model info - Stores-->>UI: reactive update - end - API-->>Stores: done + timings - Stores->>DB: save assistant message + model used - - Note over User,API: 🔁 Regenerate (optional: different model) - - User->>UI: regenerate - Stores->>Stores: validate modalities up to this message - Stores->>DB: create message branch - Note right of Stores: same streaming flow - - Note over User,API: ⏹️ Stop - - User->>UI: stop - Stores->>Stores: abort stream - Stores->>DB: save partial response - - Note over User,API: 🗑️ LRU Unloading - - Note right of API: Server auto-unloads LRU models
when cache full - User->>UI: select unloaded model - Note right of Stores: triggers load flow again -``` diff --git a/tools/server/webui/docs/flows/database-flow.md b/tools/server/webui/docs/flows/database-flow.md deleted file mode 100644 index 38cd6941c..000000000 --- a/tools/server/webui/docs/flows/database-flow.md +++ /dev/null @@ -1,174 +0,0 @@ -```mermaid -sequenceDiagram - participant Store as 🗄️ Stores - participant DbSvc as ⚙️ DatabaseService - participant Dexie as 📦 Dexie ORM - participant IDB as 💾 IndexedDB - - Note over DbSvc: Stateless service - all methods static
Database: "LlamacppWebui" - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over Store,IDB: 📊 SCHEMA - %% ═══════════════════════════════════════════════════════════════════════════ - - rect rgb(240, 248, 255) - Note over IDB: conversations table:
id (PK), lastModified, currNode, name - end - - rect rgb(255, 248, 240) - Note over IDB: messages table:
id (PK), convId (FK), type, role, timestamp,
parent, children[], content, thinking,
toolCalls, extra[], model, timings - end - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over Store,IDB: 💬 CONVERSATIONS CRUD - %% ═══════════════════════════════════════════════════════════════════════════ - - Store->>DbSvc: createConversation(name) - activate DbSvc - DbSvc->>DbSvc: Generate UUID - DbSvc->>Dexie: db.conversations.add({id, name, lastModified, currNode: ""}) - Dexie->>IDB: INSERT - IDB-->>Dexie: success - DbSvc-->>Store: DatabaseConversation - deactivate DbSvc - - Store->>DbSvc: getConversation(convId) - DbSvc->>Dexie: db.conversations.get(convId) - Dexie->>IDB: SELECT WHERE id = ? - IDB-->>DbSvc: DatabaseConversation - - Store->>DbSvc: getAllConversations() - DbSvc->>Dexie: db.conversations.orderBy('lastModified').reverse().toArray() - Dexie->>IDB: SELECT ORDER BY lastModified DESC - IDB-->>DbSvc: DatabaseConversation[] - - Store->>DbSvc: updateConversation(convId, updates) - DbSvc->>Dexie: db.conversations.update(convId, {...updates, lastModified}) - Dexie->>IDB: UPDATE - - Store->>DbSvc: deleteConversation(convId) - activate DbSvc - DbSvc->>Dexie: db.conversations.delete(convId) - Dexie->>IDB: DELETE FROM conversations - DbSvc->>Dexie: db.messages.where('convId').equals(convId).delete() - Dexie->>IDB: DELETE FROM messages WHERE convId = ? - deactivate DbSvc - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over Store,IDB: 📝 MESSAGES CRUD - %% ═══════════════════════════════════════════════════════════════════════════ - - Store->>DbSvc: createRootMessage(convId) - activate DbSvc - DbSvc->>DbSvc: Create root message {type: "root", parent: null} - DbSvc->>Dexie: db.messages.add(rootMsg) - Dexie->>IDB: INSERT - DbSvc-->>Store: rootMessageId - deactivate DbSvc - - Store->>DbSvc: createSystemMessage(convId, content, parentId) - activate DbSvc - DbSvc->>DbSvc: Create message {role: "system", parent: parentId} - DbSvc->>Dexie: db.messages.add(systemMsg) - Dexie->>IDB: INSERT - DbSvc-->>Store: DatabaseMessage - deactivate DbSvc - - Store->>DbSvc: createMessageBranch(message, parentId) - activate DbSvc - DbSvc->>DbSvc: Generate UUID for new message - DbSvc->>Dexie: db.messages.add({...message, id, parent: parentId}) - Dexie->>IDB: INSERT message - - alt parentId exists - DbSvc->>Dexie: db.messages.get(parentId) - Dexie->>IDB: SELECT parent - DbSvc->>DbSvc: parent.children.push(newId) - DbSvc->>Dexie: db.messages.update(parentId, {children}) - Dexie->>IDB: UPDATE parent.children - end - - DbSvc->>Dexie: db.conversations.update(convId, {currNode: newId}) - Dexie->>IDB: UPDATE conversation.currNode - DbSvc-->>Store: DatabaseMessage - deactivate DbSvc - - Store->>DbSvc: getConversationMessages(convId) - DbSvc->>Dexie: db.messages.where('convId').equals(convId).toArray() - Dexie->>IDB: SELECT WHERE convId = ? - IDB-->>DbSvc: DatabaseMessage[] - - Store->>DbSvc: updateMessage(msgId, updates) - DbSvc->>Dexie: db.messages.update(msgId, updates) - Dexie->>IDB: UPDATE - - Store->>DbSvc: deleteMessage(msgId) - DbSvc->>Dexie: db.messages.delete(msgId) - Dexie->>IDB: DELETE - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over Store,IDB: 🌳 BRANCHING OPERATIONS - %% ═══════════════════════════════════════════════════════════════════════════ - - Store->>DbSvc: updateCurrentNode(convId, nodeId) - DbSvc->>Dexie: db.conversations.update(convId, {currNode: nodeId, lastModified}) - Dexie->>IDB: UPDATE - - Store->>DbSvc: deleteMessageCascading(msgId) - activate DbSvc - DbSvc->>DbSvc: findDescendantMessages(msgId, allMessages) - Note right of DbSvc: Recursively find all children - loop each descendant - DbSvc->>Dexie: db.messages.delete(descendantId) - Dexie->>IDB: DELETE - end - DbSvc->>Dexie: db.messages.delete(msgId) - Dexie->>IDB: DELETE target message - - alt target message has a parent - DbSvc->>Dexie: db.messages.get(parentId) - DbSvc->>DbSvc: parent.children.filter(id !== msgId) - DbSvc->>Dexie: db.messages.update(parentId, {children}) - Note right of DbSvc: Remove deleted message from parent's children[] - end - deactivate DbSvc - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over Store,IDB: 📥 IMPORT - %% ═══════════════════════════════════════════════════════════════════════════ - - Store->>DbSvc: importConversations(data) - activate DbSvc - loop each conversation in data - DbSvc->>Dexie: db.conversations.get(conv.id) - alt conversation already exists - Note right of DbSvc: Skip duplicate (keep existing) - else conversation is new - DbSvc->>Dexie: db.conversations.add(conversation) - Dexie->>IDB: INSERT conversation - loop each message - DbSvc->>Dexie: db.messages.add(message) - Dexie->>IDB: INSERT message - end - end - end - deactivate DbSvc - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over Store,IDB: 🔗 MESSAGE TREE UTILITIES - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over DbSvc: Used by stores (imported from utils): - - rect rgb(240, 255, 240) - Note over DbSvc: filterByLeafNodeId(messages, leafId)
→ Returns path from root to leaf
→ Used to display current branch - end - - rect rgb(240, 255, 240) - Note over DbSvc: findLeafNode(startId, messages)
→ Traverse to deepest child
→ Used for branch navigation - end - - rect rgb(240, 255, 240) - Note over DbSvc: findDescendantMessages(msgId, messages)
→ Find all children recursively
→ Used for cascading deletes - end -``` diff --git a/tools/server/webui/docs/flows/mcp-flow.md b/tools/server/webui/docs/flows/mcp-flow.md deleted file mode 100644 index c8aa66659..000000000 --- a/tools/server/webui/docs/flows/mcp-flow.md +++ /dev/null @@ -1,226 +0,0 @@ -```mermaid -sequenceDiagram - participant UI as 🧩 McpServersSettings / ChatForm - participant chatStore as 🗄️ chatStore - participant mcpStore as 🗄️ mcpStore - participant mcpResStore as 🗄️ mcpResourceStore - participant convStore as 🗄️ conversationsStore - participant MCPSvc as ⚙️ MCPService - participant LS as 💾 LocalStorage - participant ExtMCP as 🔌 External MCP Server - - Note over mcpStore: State:
isInitializing, error
toolCount, connectedServers
healthChecks (Map)
connections (Map)
toolsIndex (Map)
serverConfigs (Map) - - Note over mcpResStore: State:
serverResources (Map)
cachedResources (Map)
subscriptions (Map)
attachments[] - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,ExtMCP: 🚀 INITIALIZATION (App Startup) - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>mcpStore: ensureInitialized() - activate mcpStore - - mcpStore->>LS: get(MCP_SERVERS_LOCALSTORAGE_KEY) - LS-->>mcpStore: MCPServerSettingsEntry[] - - mcpStore->>mcpStore: parseServerSettings(servers) - Note right of mcpStore: Filter enabled servers
Build MCPServerConfig objects
Per-chat overrides checked via convStore - - loop For each enabled server - mcpStore->>mcpStore: runHealthCheck(serverId) - mcpStore->>mcpStore: updateHealthCheck(id, CONNECTING) - - mcpStore->>MCPSvc: connect(serverName, config, clientInfo, capabilities, onPhase) - activate MCPSvc - - MCPSvc->>MCPSvc: createTransport(config) - Note right of MCPSvc: WebSocket / StreamableHTTP / SSE
with optional CORS proxy - - MCPSvc->>ExtMCP: Transport handshake - ExtMCP-->>MCPSvc: Connection established - - MCPSvc->>ExtMCP: Initialize request - Note right of ExtMCP: Exchange capabilities
Server info, protocol version - - ExtMCP-->>MCPSvc: InitializeResult (serverInfo, capabilities) - - MCPSvc->>ExtMCP: listTools() - ExtMCP-->>MCPSvc: Tool[] - - MCPSvc-->>mcpStore: MCPConnection - deactivate MCPSvc - - mcpStore->>mcpStore: connections.set(serverName, connection) - mcpStore->>mcpStore: indexTools(connection.tools, serverName) - Note right of mcpStore: toolsIndex.set(toolName, serverName)
Handle name conflicts with prefixes - - mcpStore->>mcpStore: updateHealthCheck(id, SUCCESS) - mcpStore->>mcpStore: _connectedServers.push(serverName) - - alt Server supports resources - mcpStore->>MCPSvc: listAllResources(connection) - MCPSvc->>ExtMCP: listResources() - ExtMCP-->>MCPSvc: MCPResource[] - MCPSvc-->>mcpStore: resources - - mcpStore->>MCPSvc: listAllResourceTemplates(connection) - MCPSvc->>ExtMCP: listResourceTemplates() - ExtMCP-->>MCPSvc: MCPResourceTemplate[] - MCPSvc-->>mcpStore: templates - - mcpStore->>mcpResStore: setServerResources(serverName, resources, templates) - end - end - - mcpStore->>mcpStore: _isInitializing = false - deactivate mcpStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,ExtMCP: 🔧 TOOL EXECUTION (Chat with Tools) - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>mcpStore: executeTool(mcpCall: MCPToolCall, signal?) - activate mcpStore - - mcpStore->>mcpStore: toolsIndex.get(mcpCall.function.name) - Note right of mcpStore: Resolve serverName from toolsIndex
MCPToolCall = {id, type, function: {name, arguments}} - - mcpStore->>mcpStore: acquireConnection() - Note right of mcpStore: activeFlowCount++
Prevent shutdown during execution - - mcpStore->>mcpStore: connection = connections.get(serverName) - - mcpStore->>MCPSvc: callTool(connection, {name, arguments}, signal) - activate MCPSvc - - MCPSvc->>MCPSvc: throwIfAborted(signal) - MCPSvc->>ExtMCP: callTool(name, arguments) - - alt Tool execution success - ExtMCP-->>MCPSvc: ToolCallResult (content, isError) - MCPSvc->>MCPSvc: formatToolResult(result) - Note right of MCPSvc: Handle text, image (base64),
embedded resource content - MCPSvc-->>mcpStore: ToolExecutionResult - else Tool execution error - ExtMCP-->>MCPSvc: Error - MCPSvc-->>mcpStore: throw Error - else Aborted - MCPSvc-->>mcpStore: throw AbortError - end - - deactivate MCPSvc - - mcpStore->>mcpStore: releaseConnection() - Note right of mcpStore: activeFlowCount-- - - mcpStore-->>UI: ToolExecutionResult - deactivate mcpStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,ExtMCP: � RESOURCE ATTACHMENT CONSUMPTION - %% ═══════════════════════════════════════════════════════════════════════════ - - chatStore->>mcpStore: consumeResourceAttachmentsAsExtras() - activate mcpStore - mcpStore->>mcpResStore: getAttachments() - mcpResStore-->>mcpStore: MCPResourceAttachment[] - mcpStore->>mcpStore: Convert attachments to message extras - mcpStore->>mcpResStore: clearAttachments() - mcpStore-->>chatStore: MessageExtra[] (for user message) - deactivate mcpStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,ExtMCP: �📝 PROMPT OPERATIONS - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>mcpStore: getAllPrompts() - activate mcpStore - - loop For each connected server with prompts capability - mcpStore->>MCPSvc: listPrompts(connection) - MCPSvc->>ExtMCP: listPrompts() - ExtMCP-->>MCPSvc: Prompt[] - MCPSvc-->>mcpStore: prompts - end - - mcpStore-->>UI: MCPPromptInfo[] (with serverName) - deactivate mcpStore - - UI->>mcpStore: getPrompt(serverName, promptName, args?) - activate mcpStore - - mcpStore->>MCPSvc: getPrompt(connection, name, args) - MCPSvc->>ExtMCP: getPrompt({name, arguments}) - ExtMCP-->>MCPSvc: GetPromptResult (messages) - MCPSvc-->>mcpStore: GetPromptResult - - mcpStore-->>UI: GetPromptResult - deactivate mcpStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,ExtMCP: 📁 RESOURCE OPERATIONS - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>mcpResStore: addAttachment(resourceInfo) - activate mcpResStore - mcpResStore->>mcpResStore: Create MCPResourceAttachment (loading: true) - mcpResStore-->>UI: attachment - - UI->>mcpStore: readResource(serverName, uri) - activate mcpStore - - mcpStore->>MCPSvc: readResource(connection, uri) - MCPSvc->>ExtMCP: readResource({uri}) - ExtMCP-->>MCPSvc: MCPReadResourceResult (contents) - MCPSvc-->>mcpStore: contents - - mcpStore-->>UI: MCPResourceContent[] - deactivate mcpStore - - UI->>mcpResStore: updateAttachmentContent(attachmentId, content) - mcpResStore->>mcpResStore: cacheResourceContent(resource, content) - deactivate mcpResStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,ExtMCP: 🔄 AUTO-RECONNECTION - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over mcpStore: On WebSocket close or connection error: - mcpStore->>mcpStore: autoReconnect(serverName, attempt) - activate mcpStore - - mcpStore->>mcpStore: Calculate backoff delay - Note right of mcpStore: delay = min(30s, 1s * 2^attempt) - - mcpStore->>mcpStore: Wait for delay - mcpStore->>mcpStore: reconnectServer(serverName) - - alt Reconnection success - mcpStore->>mcpStore: updateHealthCheck(id, SUCCESS) - else Max attempts reached - mcpStore->>mcpStore: updateHealthCheck(id, ERROR) - end - deactivate mcpStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,ExtMCP: 🛑 SHUTDOWN - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>mcpStore: shutdown() - activate mcpStore - - mcpStore->>mcpStore: Wait for activeFlowCount == 0 - - loop For each connection - mcpStore->>MCPSvc: disconnect(connection) - MCPSvc->>MCPSvc: transport.onclose = undefined - MCPSvc->>ExtMCP: close() - end - - mcpStore->>mcpStore: connections.clear() - mcpStore->>mcpStore: toolsIndex.clear() - mcpStore->>mcpStore: _connectedServers = [] - - mcpStore->>mcpResStore: clear() - deactivate mcpStore -``` diff --git a/tools/server/webui/docs/flows/models-flow.md b/tools/server/webui/docs/flows/models-flow.md deleted file mode 100644 index c3031b729..000000000 --- a/tools/server/webui/docs/flows/models-flow.md +++ /dev/null @@ -1,181 +0,0 @@ -```mermaid -sequenceDiagram - participant UI as 🧩 ModelsSelector - participant Hooks as 🪝 useModelChangeValidation - participant modelsStore as 🗄️ modelsStore - participant serverStore as 🗄️ serverStore - participant convStore as 🗄️ conversationsStore - participant ModelsSvc as ⚙️ ModelsService - participant PropsSvc as ⚙️ PropsService - participant API as 🌐 llama-server - - Note over modelsStore: State:
models: ModelOption[]
routerModels: ApiModelDataEntry[]
selectedModelId, selectedModelName
loading, updating, error
modelLoadingStates (Map)
modelPropsCache (Map)
propsCacheVersion - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: 🚀 INITIALIZATION (MODEL mode) - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>modelsStore: fetch() - activate modelsStore - modelsStore->>modelsStore: loading = true - - alt serverStore.props not loaded - modelsStore->>serverStore: fetch() - Note over serverStore: → see server-flow.mmd - end - - modelsStore->>ModelsSvc: list() - ModelsSvc->>API: GET /v1/models - API-->>ModelsSvc: ApiModelListResponse {data: [model]} - - modelsStore->>modelsStore: models = $state(mapped) - Note right of modelsStore: Map to ModelOption[]:
{id, name, model, description, capabilities} - - Note over modelsStore: MODEL mode: Get modalities from serverStore.props - modelsStore->>modelsStore: modelPropsCache.set(model.id, serverStore.props) - modelsStore->>modelsStore: models[0].modalities = props.modalities - - modelsStore->>modelsStore: Auto-select single model - Note right of modelsStore: selectedModelId = models[0].id - modelsStore->>modelsStore: loading = false - deactivate modelsStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: 🚀 INITIALIZATION (ROUTER mode) - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>modelsStore: fetch() - activate modelsStore - modelsStore->>ModelsSvc: list() - ModelsSvc->>API: GET /v1/models - API-->>ModelsSvc: ApiModelListResponse - modelsStore->>modelsStore: models = $state(mapped) - deactivate modelsStore - - Note over UI: After models loaded, layout triggers: - UI->>modelsStore: fetchRouterModels() - activate modelsStore - modelsStore->>ModelsSvc: listRouter() - ModelsSvc->>API: GET /v1/models - API-->>ModelsSvc: ApiRouterModelsListResponse - Note right of API: {data: [{id, status, path, in_cache}]} - modelsStore->>modelsStore: routerModels = $state(data) - - modelsStore->>modelsStore: fetchModalitiesForLoadedModels() - loop each model where status === "loaded" - modelsStore->>PropsSvc: fetchForModel(modelId) - PropsSvc->>API: GET /props?model={modelId} - API-->>PropsSvc: ApiLlamaCppServerProps - modelsStore->>modelsStore: modelPropsCache.set(modelId, props) - end - modelsStore->>modelsStore: propsCacheVersion++ - deactivate modelsStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: 🔄 MODEL SELECTION (ROUTER mode) - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>Hooks: useModelChangeValidation({getRequiredModalities, onSuccess?, onValidationFailure?}) - Note over Hooks: Hook configured per-component:
ChatForm: getRequiredModalities = usedModalities
ChatMessage: getRequiredModalities = getModalitiesUpToMessage(msgId) - - UI->>Hooks: handleModelChange(modelId, modelName) - activate Hooks - Hooks->>Hooks: previousSelectedModelId = modelsStore.selectedModelId - Hooks->>modelsStore: isModelLoaded(modelName)? - - alt model NOT loaded - Hooks->>modelsStore: loadModel(modelName) - Note over modelsStore: → see LOAD MODEL section below - end - - Note over Hooks: Always fetch props (from cache or API) - Hooks->>modelsStore: fetchModelProps(modelName) - modelsStore-->>Hooks: props - - Hooks->>convStore: getRequiredModalities() - convStore-->>Hooks: {vision, audio} - - Hooks->>Hooks: Validate: model.modalities ⊇ required? - - alt validation PASSED - Hooks->>modelsStore: selectModelById(modelId) - Hooks-->>UI: return true - else validation FAILED - Hooks->>UI: toast.error("Model doesn't support required modalities") - alt model was just loaded - Hooks->>modelsStore: unloadModel(modelName) - end - alt onValidationFailure provided - Hooks->>modelsStore: selectModelById(previousSelectedModelId) - end - Hooks-->>UI: return false - end - deactivate Hooks - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: ⬆️ LOAD MODEL (ROUTER mode) - %% ═══════════════════════════════════════════════════════════════════════════ - - modelsStore->>modelsStore: loadModel(modelId) - activate modelsStore - - alt already loaded - modelsStore-->>modelsStore: return (no-op) - end - - modelsStore->>modelsStore: modelLoadingStates.set(modelId, true) - modelsStore->>ModelsSvc: load(modelId) - ModelsSvc->>API: POST /models/load {model: modelId} - API-->>ModelsSvc: {status: "loading"} - - modelsStore->>modelsStore: pollForModelStatus(modelId, LOADED) - loop poll every 500ms (max 60 attempts) - modelsStore->>modelsStore: fetchRouterModels() - modelsStore->>ModelsSvc: listRouter() - ModelsSvc->>API: GET /v1/models - API-->>ModelsSvc: models[] - modelsStore->>modelsStore: getModelStatus(modelId) - alt status === LOADED - Note right of modelsStore: break loop - else status === LOADING - Note right of modelsStore: wait 500ms, continue - end - end - - modelsStore->>modelsStore: updateModelModalities(modelId) - modelsStore->>PropsSvc: fetchForModel(modelId) - PropsSvc->>API: GET /props?model={modelId} - API-->>PropsSvc: props with modalities - modelsStore->>modelsStore: modelPropsCache.set(modelId, props) - modelsStore->>modelsStore: propsCacheVersion++ - - modelsStore->>modelsStore: modelLoadingStates.set(modelId, false) - deactivate modelsStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: ⬇️ UNLOAD MODEL (ROUTER mode) - %% ═══════════════════════════════════════════════════════════════════════════ - - modelsStore->>modelsStore: unloadModel(modelId) - activate modelsStore - modelsStore->>modelsStore: modelLoadingStates.set(modelId, true) - modelsStore->>ModelsSvc: unload(modelId) - ModelsSvc->>API: POST /models/unload {model: modelId} - - modelsStore->>modelsStore: pollForModelStatus(modelId, UNLOADED) - loop poll until unloaded - modelsStore->>ModelsSvc: listRouter() - ModelsSvc->>API: GET /v1/models - end - - modelsStore->>modelsStore: modelLoadingStates.set(modelId, false) - deactivate modelsStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: 📊 COMPUTED GETTERS - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over modelsStore: Getters:
- selectedModel: ModelOption | null
- loadedModelIds: string[] (from routerModels)
- loadingModelIds: string[] (from modelLoadingStates)
- singleModelName: string | null (MODEL mode only) - - Note over modelsStore: Modality helpers:
- getModelModalities(modelId): {vision, audio}
- modelSupportsVision(modelId): boolean
- modelSupportsAudio(modelId): boolean -``` diff --git a/tools/server/webui/docs/flows/server-flow.md b/tools/server/webui/docs/flows/server-flow.md deleted file mode 100644 index d6a1611f6..000000000 --- a/tools/server/webui/docs/flows/server-flow.md +++ /dev/null @@ -1,76 +0,0 @@ -```mermaid -sequenceDiagram - participant UI as 🧩 +layout.svelte - participant serverStore as 🗄️ serverStore - participant PropsSvc as ⚙️ PropsService - participant API as 🌐 llama-server - - Note over serverStore: State:
props: ApiLlamaCppServerProps | null
loading, error
role: ServerRole | null (MODEL | ROUTER)
fetchPromise (deduplication) - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: 🚀 INITIALIZATION - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>serverStore: fetch() - activate serverStore - - alt fetchPromise exists (already fetching) - serverStore-->>UI: return fetchPromise - Note right of serverStore: Deduplicate concurrent calls - end - - serverStore->>serverStore: loading = true - serverStore->>serverStore: fetchPromise = new Promise() - - serverStore->>PropsSvc: fetch() - PropsSvc->>API: GET /props - API-->>PropsSvc: ApiLlamaCppServerProps - Note right of API: {role, model_path, model_alias,
modalities, default_generation_settings, ...} - - PropsSvc-->>serverStore: props - serverStore->>serverStore: props = $state(data) - - serverStore->>serverStore: detectRole(props) - Note right of serverStore: role = props.role === "router"
? ServerRole.ROUTER
: ServerRole.MODEL - - serverStore->>serverStore: loading = false - serverStore->>serverStore: fetchPromise = null - deactivate serverStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: 📊 COMPUTED GETTERS - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over serverStore: Getters from props: - - rect rgb(240, 255, 240) - Note over serverStore: defaultParams
→ props.default_generation_settings.params
(temperature, top_p, top_k, etc.) - end - - rect rgb(240, 255, 240) - Note over serverStore: contextSize
→ props.default_generation_settings.n_ctx - end - - rect rgb(255, 240, 240) - Note over serverStore: isRouterMode
→ role === ServerRole.ROUTER - end - - rect rgb(255, 240, 240) - Note over serverStore: isModelMode
→ role === ServerRole.MODEL - end - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: 🔗 RELATIONSHIPS - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over serverStore: Used by: - Note right of serverStore: - modelsStore: role detection, MODEL mode modalities
- settingsStore: syncWithServerDefaults (defaultParams)
- chatStore: contextSize for processing state
- UI components: isRouterMode for conditional rendering - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,API: ❌ ERROR HANDLING - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over serverStore: getErrorMessage(): string | null
Returns formatted error for UI display - - Note over serverStore: clear(): void
Resets all state (props, error, loading, role) -``` diff --git a/tools/server/webui/docs/flows/settings-flow.md b/tools/server/webui/docs/flows/settings-flow.md deleted file mode 100644 index 40ad3bd94..000000000 --- a/tools/server/webui/docs/flows/settings-flow.md +++ /dev/null @@ -1,156 +0,0 @@ -```mermaid -sequenceDiagram - participant UI as 🧩 ChatSettings - participant settingsStore as 🗄️ settingsStore - participant serverStore as 🗄️ serverStore - participant ParamSvc as ⚙️ ParameterSyncService - participant LS as 💾 LocalStorage - - Note over settingsStore: State:
config: SettingsConfigType
theme: string ("auto" | "light" | "dark")
isInitialized: boolean
userOverrides: Set<string> - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,LS: 🚀 INITIALIZATION - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over settingsStore: Auto-initialized in constructor (browser only) - settingsStore->>settingsStore: initialize() - activate settingsStore - - settingsStore->>settingsStore: loadConfig() - settingsStore->>LS: get("llama-config") - LS-->>settingsStore: StoredConfig | null - - alt config exists - settingsStore->>settingsStore: Merge with SETTING_CONFIG_DEFAULT - Note right of settingsStore: Fill missing keys with defaults - else no config - settingsStore->>settingsStore: config = SETTING_CONFIG_DEFAULT - end - - settingsStore->>LS: get("llama-userOverrides") - LS-->>settingsStore: string[] | null - settingsStore->>settingsStore: userOverrides = new Set(data) - - settingsStore->>settingsStore: loadTheme() - settingsStore->>LS: get("llama-theme") - LS-->>settingsStore: theme | "auto" - - settingsStore->>settingsStore: isInitialized = true - deactivate settingsStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,LS: 🔄 SYNC WITH SERVER DEFAULTS - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over UI: Triggered from +layout.svelte when serverStore.props loaded - UI->>settingsStore: syncWithServerDefaults() - activate settingsStore - - settingsStore->>serverStore: defaultParams - serverStore-->>settingsStore: {temperature, top_p, top_k, ...} - - loop each SYNCABLE_PARAMETER - alt key NOT in userOverrides - settingsStore->>settingsStore: config[key] = serverDefault[key] - Note right of settingsStore: Non-overridden params adopt server default - else key in userOverrides - Note right of settingsStore: Keep user value, skip server default - end - end - - alt serverStore.props has webuiSettings - settingsStore->>settingsStore: Apply webuiSettings from server - Note right of settingsStore: Server-provided UI settings
(e.g. showRawOutputSwitch) - end - - settingsStore->>settingsStore: saveConfig() - deactivate settingsStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,LS: ⚙️ UPDATE CONFIG - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>settingsStore: updateConfig(key, value) - activate settingsStore - settingsStore->>settingsStore: config[key] = value - - alt value matches server default for key - settingsStore->>settingsStore: userOverrides.delete(key) - Note right of settingsStore: Matches server default, remove override - else value differs from server default - settingsStore->>settingsStore: userOverrides.add(key) - Note right of settingsStore: Mark as user-modified (won't be overwritten) - end - - settingsStore->>settingsStore: saveConfig() - settingsStore->>LS: set(CONFIG_LOCALSTORAGE_KEY, config) - settingsStore->>LS: set(USER_OVERRIDES_LOCALSTORAGE_KEY, [...userOverrides]) - deactivate settingsStore - - UI->>settingsStore: updateMultipleConfig({key1: val1, key2: val2}) - activate settingsStore - Note right of settingsStore: Batch update, single save - settingsStore->>settingsStore: For each key: config[key] = value - settingsStore->>settingsStore: For each key: userOverrides.add(key) - settingsStore->>settingsStore: saveConfig() - deactivate settingsStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,LS: 🔄 RESET - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>settingsStore: resetConfig() - activate settingsStore - settingsStore->>settingsStore: config = {...SETTING_CONFIG_DEFAULT} - settingsStore->>settingsStore: userOverrides.clear() - Note right of settingsStore: All params reset to defaults
Next syncWithServerDefaults will adopt server values - settingsStore->>settingsStore: saveConfig() - deactivate settingsStore - - UI->>settingsStore: resetParameterToServerDefault(key) - activate settingsStore - settingsStore->>settingsStore: userOverrides.delete(key) - settingsStore->>serverStore: defaultParams[key] - settingsStore->>settingsStore: config[key] = serverDefault - settingsStore->>settingsStore: saveConfig() - deactivate settingsStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,LS: 🎨 THEME - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>settingsStore: updateTheme(newTheme) - activate settingsStore - settingsStore->>settingsStore: theme = newTheme - settingsStore->>settingsStore: saveTheme() - settingsStore->>LS: set("llama-theme", theme) - deactivate settingsStore - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,LS: 📊 PARAMETER INFO - %% ═══════════════════════════════════════════════════════════════════════════ - - UI->>settingsStore: getParameterInfo(key) - settingsStore->>ParamSvc: getParameterInfo(key, config, serverDefaults, userOverrides) - ParamSvc-->>settingsStore: ParameterInfo - Note right of ParamSvc: {
currentValue,
serverDefault,
isUserOverride: boolean,
canSync: boolean,
isDifferentFromServer: boolean
} - - UI->>settingsStore: getParameterDiff() - settingsStore->>ParamSvc: createParameterDiff(config, serverDefaults, userOverrides) - ParamSvc-->>settingsStore: ParameterDiff[] - Note right of ParamSvc: Array of parameters where user != server - - %% ═══════════════════════════════════════════════════════════════════════════ - Note over UI,LS: 📋 CONFIG CATEGORIES - %% ═══════════════════════════════════════════════════════════════════════════ - - Note over settingsStore: Syncable with server (from /props): - rect rgb(240, 255, 240) - Note over settingsStore: temperature, top_p, top_k, min_p
repeat_penalty, presence_penalty, frequency_penalty
dynatemp_range, dynatemp_exponent
typ_p, xtc_probability, xtc_threshold
dry_multiplier, dry_base, dry_allowed_length, dry_penalty_last_n - end - - Note over settingsStore: UI-only (not synced): - rect rgb(255, 240, 240) - Note over settingsStore: systemMessage, custom (JSON)
showStatistics, enableContinueGeneration
autoMicOnEmpty, disableAutoScroll
apiKey, pdfAsImage, disableReasoningParsing, showRawOutputSwitch - end -``` diff --git a/tools/server/webui/eslint.config.js b/tools/server/webui/eslint.config.js deleted file mode 100644 index cd20fb383..000000000 --- a/tools/server/webui/eslint.config.js +++ /dev/null @@ -1,51 +0,0 @@ -// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format -import storybook from 'eslint-plugin-storybook'; - -import prettier from 'eslint-config-prettier'; -import { includeIgnoreFile } from '@eslint/compat'; -import js from '@eslint/js'; -import svelte from 'eslint-plugin-svelte'; -import globals from 'globals'; -import { fileURLToPath } from 'node:url'; -import ts from 'typescript-eslint'; -import svelteConfig from './svelte.config.js'; - -const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); - -export default ts.config( - includeIgnoreFile(gitignorePath), - js.configs.recommended, - ...ts.configs.recommended, - ...svelte.configs.recommended, - prettier, - ...svelte.configs.prettier, - { - languageOptions: { - globals: { ...globals.browser, ...globals.node } - }, - rules: { - // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. - // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors - 'no-undef': 'off', - 'svelte/no-at-html-tags': 'off', - // This app uses hash-based routing (#/) where resolve() from $app/paths does not apply - 'svelte/no-navigation-without-resolve': 'off' - } - }, - { - files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], - languageOptions: { - parserOptions: { - projectService: true, - extraFileExtensions: ['.svelte'], - parser: ts.parser, - svelteConfig - } - } - }, - { - // Exclude Storybook files from main ESLint rules - ignores: ['.storybook/**/*'] - }, - storybook.configs['flat/recommended'] -); diff --git a/tools/server/webui/package-lock.json b/tools/server/webui/package-lock.json deleted file mode 100644 index bf23307b8..000000000 --- a/tools/server/webui/package-lock.json +++ /dev/null @@ -1,10704 +0,0 @@ -{ - "name": "llama-ui", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "llama-ui", - "version": "1.0.0", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.1", - "highlight.js": "^11.11.1", - "mode-watcher": "^1.1.0", - "pdfjs-dist": "^5.4.54", - "rehype-highlight": "^7.0.2", - "rehype-stringify": "^10.0.1", - "remark": "^15.0.1", - "remark-breaks": "^4.0.0", - "remark-gfm": "^4.0.1", - "remark-html": "^16.0.1", - "remark-rehype": "^11.1.2", - "svelte-sonner": "^1.0.5", - "unist-util-visit": "^5.0.0", - "zod": "^4.2.1" - }, - "devDependencies": { - "@chromatic-com/storybook": "^5.0.0", - "@eslint/compat": "^1.2.5", - "@eslint/js": "^9.18.0", - "@internationalized/date": "^3.10.1", - "@lucide/svelte": "^0.515.0", - "@playwright/test": "^1.49.1", - "@storybook/addon-a11y": "^10.2.4", - "@storybook/addon-docs": "^10.2.4", - "@storybook/addon-svelte-csf": "^5.0.10", - "@storybook/addon-vitest": "^10.2.4", - "@storybook/sveltekit": "^10.2.4", - "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.48.4", - "@sveltejs/vite-plugin-svelte": "^6.2.1", - "@tailwindcss/forms": "^0.5.9", - "@tailwindcss/typography": "^0.5.15", - "@tailwindcss/vite": "^4.0.0", - "@types/node": "^24", - "@vitest/browser": "^3.2.3", - "@vitest/coverage-v8": "^3.2.3", - "bits-ui": "^2.14.4", - "clsx": "^2.1.1", - "dexie": "^4.0.11", - "eslint": "^9.18.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-storybook": "^10.2.4", - "eslint-plugin-svelte": "^3.0.0", - "globals": "^16.0.0", - "http-server": "^14.1.1", - "mdast": "^3.0.0", - "mdsvex": "^0.12.3", - "playwright": "^1.56.1", - "prettier": "^3.4.2", - "prettier-plugin-svelte": "^3.3.3", - "prettier-plugin-tailwindcss": "^0.6.11", - "rehype-katex": "^7.0.1", - "remark-math": "^6.0.0", - "sass": "^1.93.3", - "storybook": "^10.2.4", - "svelte": "^5.38.2", - "svelte-check": "^4.0.0", - "tailwind-merge": "^3.3.1", - "tailwind-variants": "^3.2.2", - "tailwindcss": "^4.0.0", - "tw-animate-css": "^1.3.5", - "typescript": "^5.0.0", - "typescript-eslint": "^8.20.0", - "unified": "^11.0.5", - "uuid": "^13.0.0", - "vite": "^7.2.2", - "vite-plugin-devtools-json": "^0.2.0", - "vitest": "^3.2.3", - "vitest-browser-svelte": "^0.1.0" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@chromatic-com/storybook": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.0.0.tgz", - "integrity": "sha512-8wUsqL8kg6R5ue8XNE7Jv/iD1SuE4+6EXMIGIuE+T2loBITEACLfC3V8W44NJviCLusZRMWbzICddz0nU0bFaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@neoconfetti/react": "^1.0.0", - "chromatic": "^13.3.4", - "filesize": "^10.0.12", - "jsonfile": "^6.1.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20.0.0", - "yarn": ">=1.22.18" - }, - "peerDependencies": { - "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/compat": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.1.tgz", - "integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "peerDependencies": { - "eslint": "^8.40 || 9" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "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.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", - "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz", - "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.2", - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@internationalized/date": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz", - "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@lucide/svelte": { - "version": "0.515.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.515.0.tgz", - "integrity": "sha512-CEAyqcZmNBfYzVgaRmK2RFJP5tnbXxekRyDk0XX/eZQRfsJmkDvmQwXNX8C869BgNeryzmrRyjHhUL6g9ZOHNA==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "svelte": "^5" - } - }, - "node_modules/@mdx-js/react": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz", - "integrity": "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdx": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=16", - "react": ">=16" - } - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/@napi-rs/canvas": { - "version": "0.1.76", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.76.tgz", - "integrity": "sha512-YIk5okeNN53GzjvWmAyCQFE9xrLeQXzYpudX4TiLvqaz9SqXgIgxIuKPe4DKyB5nccsQMIev7JGKTzZaN5rFdw==", - "license": "MIT", - "optional": true, - "workspaces": [ - "e2e/*" - ], - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.76", - "@napi-rs/canvas-darwin-arm64": "0.1.76", - "@napi-rs/canvas-darwin-x64": "0.1.76", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.76", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.76", - "@napi-rs/canvas-linux-arm64-musl": "0.1.76", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.76", - "@napi-rs/canvas-linux-x64-gnu": "0.1.76", - "@napi-rs/canvas-linux-x64-musl": "0.1.76", - "@napi-rs/canvas-win32-x64-msvc": "0.1.76" - } - }, - "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.76", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.76.tgz", - "integrity": "sha512-7EAfkLBQo2QoEzpHdInFbfEUYTXsiO2hvtFo1D9zfTzcQM8n5piZdOpJ3EIkmpe8yLoSV8HLyUQtq4bv11x6Tg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.76", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.76.tgz", - "integrity": "sha512-Cs8WRMzaWSJWeWY8tvnCe+TuduHUbB0xFhZ0FmOrNy2prPxT4A6aU3FQu8hR9XJw8kKZ7v902wzaDmy9SdhG8A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.76", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.76.tgz", - "integrity": "sha512-ya+T6gV9XAq7YAnMa2fKhWXAuRR5cpRny2IoHacoMxgtOARnUkJO/k3hIb52FtMoq7UxLi5+IFGVHU6ZiMu4Ag==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.76", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.76.tgz", - "integrity": "sha512-fgnPb+FKVuixACvkHGldJqYXExORBwvqGgL0K80uE6SGH2t0UKD2auHw2CtBy14DUzfg82PkupO2ix2w7kB+Xw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.76", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.76.tgz", - "integrity": "sha512-r8OxIenvBPOa4I014k1ZWTCz2dB0ZTsxMP7+ovMOKO7jkl1Z+YZo2OTAqxArpMhN0wdEeI3Lw9zUcn2HgwEgDA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.76", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.76.tgz", - "integrity": "sha512-smxwzKfHYaOYG7QXUuDPrFEC7WqjL3Lx4AM6mk8/FxDAS+8o0eoZJwSu+zXsaBLimEQUozEYgEGtJ2JJ0RdL4A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.76", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.76.tgz", - "integrity": "sha512-G2PsFwsP+r4syEoNLStV3n1wtNAClwf8s/qB57bexG08R4f4WaiBd+x+d4iYS0Y5o90YIEm8/ewZn4bLIa0wNQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.76", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.76.tgz", - "integrity": "sha512-SNK+vgge4DnuONYdYE3Y09LivGgUiUPQDU+PdGNZJIzIi0hRDLcA59eag8LGeQfPmJW84c1aZD04voihybKFog==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.76", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.76.tgz", - "integrity": "sha512-tWHLBI9iVoR1NsfpHz1MGERTkqcca8akbH/CzX6JQUNC+lJOeYYXeRuK8hKqMIg1LI+4QOMAtHNVeZu8NvjEug==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.76", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.76.tgz", - "integrity": "sha512-ifM5HOGw2hP5QLQzCB41Riw3Pq5yKAAjZpn+lJC0sYBmyS2s/Kq6KpTOKxf0CuptkI1wMcRcYQfhLRdeWiYvIg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@neoconfetti/react": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@neoconfetti/react/-/react-1.0.0.tgz", - "integrity": "sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher/node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@playwright/test": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", - "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.56.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@storybook/addon-a11y": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.2.4.tgz", - "integrity": "sha512-VGhdZ+iP2l/CSulIKV2kt3SMWVHntOigqWqGkNYf6YNYofynUYEKdsNqBvHx4ySuNEl/eXJ8LRO8FKYnU7LxZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "axe-core": "^4.2.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^10.2.4" - } - }, - "node_modules/@storybook/addon-docs": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.4.tgz", - "integrity": "sha512-FzscAmdBiOGnGrxiEM+8eTg43kjqgjLfObg+lbJVRR/a0DmZ3xfAPNB0+VKYQbN0FacNcWLM9LZ/7U0hRBPBnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.2.4", - "@storybook/icons": "^2.0.1", - "@storybook/react-dom-shim": "10.2.4", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^10.2.4" - } - }, - "node_modules/@storybook/addon-svelte-csf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@storybook/addon-svelte-csf/-/addon-svelte-csf-5.0.10.tgz", - "integrity": "sha512-poSvTS7VdaQ42ZoqW5e4+2Hv1iLO0mekH9fwn/QuBNse48R4WlTyR8XFbHRTfatl9gdc9ZYC4uWzazrmV6zGIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/csf": "^0.1.13", - "dedent": "^1.5.3", - "es-toolkit": "^1.26.1", - "esrap": "^1.2.2", - "magic-string": "^0.30.12", - "svelte-ast-print": "^0.4.0", - "zimmerframe": "^1.1.2" - }, - "peerDependencies": { - "@storybook/svelte": "^0.0.0-0 || ^8.2.0 || ^9.0.0 || ^9.1.0-0 || ^10.0.0-0", - "@sveltejs/vite-plugin-svelte": "^4.0.0 || ^5.0.0 || ^6.0.0", - "storybook": "^0.0.0-0 || ^8.2.0 || ^9.0.0 || ^9.1.0-0 || ^10.0.0-0", - "svelte": "^5.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@storybook/addon-vitest": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.2.4.tgz", - "integrity": "sha512-BT1iP89U4wcbpzTURU8WYTAeUcdNh4WIt0BqsnATmMwR/jKNJW6QgXCVqGQTSpRjWj40hX5e2JkQYCNXdjKsPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "@storybook/icons": "^2.0.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "@vitest/browser": "^3.0.0 || ^4.0.0", - "@vitest/browser-playwright": "^4.0.0", - "@vitest/runner": "^3.0.0 || ^4.0.0", - "storybook": "^10.2.4", - "vitest": "^3.0.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/runner": { - "optional": true - }, - "vitest": { - "optional": true - } - } - }, - "node_modules/@storybook/builder-vite": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.2.4.tgz", - "integrity": "sha512-/hcT1xj3CL5GkJ5v5/EguZdttDwNE6weNXK7vKzp034tnGcLycOossDsTiUQkBowSL+Ylc8aKj+ZgvddPNfOig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/csf-plugin": "10.2.4", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^10.2.4", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@storybook/csf": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.1.13.tgz", - "integrity": "sha512-7xOOwCLGB3ebM87eemep89MYRFTko+D8qE7EdAAq74lgdqRR5cOUtYWJLjO2dLtP94nqoOdHJo6MdLLKzg412Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^2.19.0" - } - }, - "node_modules/@storybook/csf-plugin": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.4.tgz", - "integrity": "sha512-kupPQEV+4N9mzsZHYaokvhO/KHBjYdWda9PNmPQwy0TR7r2mzthgaNH72TjmgN1L6DIbsuyOG1wtczcPJn4+Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "unplugin": "^2.3.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "esbuild": "*", - "rollup": "*", - "storybook": "^10.2.4", - "vite": "*", - "webpack": "*" - }, - "peerDependenciesMeta": { - "esbuild": { - "optional": true - }, - "rollup": { - "optional": true - }, - "vite": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/@storybook/global": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", - "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@storybook/icons": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz", - "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@storybook/react-dom-shim": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.4.tgz", - "integrity": "sha512-i22OtrZ7GeZPt/odLf0vqyDhRSKyaLsHkkKSBcANQfzRRnBZmiz2FchOtWm9uvoDWybQsTruZq7kTdtpEhwyGw==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.4" - } - }, - "node_modules/@storybook/svelte": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@storybook/svelte/-/svelte-10.2.4.tgz", - "integrity": "sha512-W9R51zUCd2iHOQBg/D93+bdpYv6kbtFx+kft5X8lPKQl6yEu0aKs9i5N5GyCASOhIApgx/tkqZIJ7vgM4cqrHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ts-dedent": "^2.0.0", - "type-fest": "~2.19" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^10.2.4", - "svelte": "^5.0.0" - } - }, - "node_modules/@storybook/svelte-vite": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@storybook/svelte-vite/-/svelte-vite-10.2.4.tgz", - "integrity": "sha512-FMgKMRdoZFDwPD6eIDMldcgp6d6NtIGuXyUJjb29qLias/gE5TI6hg+cWmmWXQRTrXwdyepeMBmIfRcZbB6REQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/builder-vite": "10.2.4", - "@storybook/svelte": "10.2.4", - "magic-string": "^0.30.0", - "svelte2tsx": "^0.7.44", - "typescript": "^4.9.4 || ^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", - "storybook": "^10.2.4", - "svelte": "^5.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@storybook/sveltekit": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@storybook/sveltekit/-/sveltekit-10.2.4.tgz", - "integrity": "sha512-1qDX35iSJHWo1AOd7HMzJtCHBfgahXqTWNiyZa/JMEKJ3qC1otaU8XMmTjsZ6fCRF99piNdgqtWM8+s1TJOldg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/builder-vite": "10.2.4", - "@storybook/svelte": "10.2.4", - "@storybook/svelte-vite": "10.2.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^10.2.4", - "svelte": "^5.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", - "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } - }, - "node_modules/@sveltejs/adapter-static": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", - "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@sveltejs/kit": "^2.0.0" - } - }, - "node_modules/@sveltejs/kit": { - "version": "2.59.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz", - "integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.5", - "@types/cookie": "^0.6.0", - "acorn": "^8.14.1", - "cookie": "^0.6.0", - "devalue": "^5.6.4", - "esm-env": "^1.2.2", - "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "mrmime": "^2.0.0", - "set-cookie-parser": "^3.0.0", - "sirv": "^3.0.0" - }, - "bin": { - "svelte-kit": "svelte-kit.js" - }, - "engines": { - "node": ">=18.13" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3 || ^6.0.0", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz", - "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", - "debug": "^4.4.1", - "deepmerge": "^4.3.1", - "magic-string": "^0.30.17", - "vitefu": "^1.1.1" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24" - }, - "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.0.tgz", - "integrity": "sha512-iwQ8Z4ET6ZFSt/gC+tVfcsSBHwsqc6RumSaiLUkAurW3BCpJam65cmHw0oOlDMTO0u+PZi9hilBRYN+LZNHTUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.1" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", - "svelte": "^5.0.0", - "vite": "^6.3.0 || ^7.0.0" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", - "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@tailwindcss/forms": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", - "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mini-svg-data-uri": "^1.2.3" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", - "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", - "lightningcss": "1.30.1", - "magic-string": "^0.30.17", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.11" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz", - "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.4", - "tar": "^7.4.3" - }, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.11", - "@tailwindcss/oxide-darwin-arm64": "4.1.11", - "@tailwindcss/oxide-darwin-x64": "4.1.11", - "@tailwindcss/oxide-freebsd-x64": "4.1.11", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", - "@tailwindcss/oxide-linux-x64-musl": "4.1.11", - "@tailwindcss/oxide-wasm32-wasi": "4.1.11", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz", - "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz", - "integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz", - "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz", - "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz", - "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz", - "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz", - "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz", - "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz", - "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz", - "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.11", - "@tybys/wasm-util": "^0.9.0", - "tslib": "^2.8.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.4.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.4.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.11", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.9.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.0", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", - "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz", - "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", - "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.11.tgz", - "integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.1.11", - "@tailwindcss/oxide": "4.1.11", - "tailwindcss": "4.1.11" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", - "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/chai": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", - "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*" - } - }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/katex": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", - "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mdx": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", - "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.10.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.10.tgz", - "integrity": "sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/react": { - "version": "19.1.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", - "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "csstype": "^3.0.2" - } - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" - }, - "node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", - "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/type-utils": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.56.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", - "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", - "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.0", - "@typescript-eslint/types": "^8.56.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", - "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", - "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", - "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", - "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.56.0", - "@typescript-eslint/tsconfig-utils": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", - "debug": "^4.4.3", - "minimatch": "^9.0.5", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", - "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", - "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.56.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" - }, - "node_modules/@vitest/browser": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.4.tgz", - "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@testing-library/dom": "^10.4.0", - "@testing-library/user-event": "^14.6.1", - "@vitest/mocker": "3.2.4", - "@vitest/utils": "3.2.4", - "magic-string": "^0.30.17", - "sirv": "^3.0.1", - "tinyrainbow": "^2.0.0", - "ws": "^8.18.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "playwright": "*", - "vitest": "3.2.4", - "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0" - }, - "peerDependenciesMeta": { - "playwright": { - "optional": true - }, - "safaridriver": { - "optional": true - }, - "webdriverio": { - "optional": true - } - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "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" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-types": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", - "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ast-v8-to-istanbul": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", - "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, - "license": "MIT" - }, - "node_modules/axe-core": { - "version": "4.10.3", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", - "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=4" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "5.1.2" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/bits-ui": { - "version": "2.18.1", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz", - "integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.1", - "@floating-ui/dom": "^1.7.1", - "esm-env": "^1.1.2", - "runed": "^0.35.1", - "svelte-toolbelt": "^0.10.6", - "tabbable": "^6.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/huntabyte" - }, - "peerDependencies": { - "@internationalized/date": "^3.8.1", - "svelte": "^5.33.0" - } - }, - "node_modules/bits-ui/node_modules/runed": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", - "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", - "dev": true, - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3", - "esm-env": "^1.0.0", - "lz-string": "^1.5.0" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.21.0", - "svelte": "^5.7.0" - }, - "peerDependenciesMeta": { - "@sveltejs/kit": { - "optional": true - } - } - }, - "node_modules/bits-ui/node_modules/svelte-toolbelt": { - "version": "0.10.6", - "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", - "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/huntabyte" - ], - "dependencies": { - "clsx": "^2.1.1", - "runed": "^0.35.1", - "style-to-object": "^1.0.8" - }, - "engines": { - "node": ">=18", - "pnpm": ">=8.7.0" - }, - "peerDependencies": { - "svelte": "^5.30.2" - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chai": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz", - "integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/chromatic": { - "version": "13.3.5", - "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.5.tgz", - "integrity": "sha512-MzPhxpl838qJUo0A55osCF2ifwPbjcIPeElr1d4SHcjnHoIcg7l1syJDrAYK/a+PcCBrOGi06jPNpQAln5hWgw==", - "dev": true, - "license": "MIT", - "bin": { - "chroma": "dist/bin.js", - "chromatic": "dist/bin.js", - "chromatic-cli": "dist/bin.js" - }, - "peerDependencies": { - "@chromatic-com/cypress": "^0.*.* || ^1.0.0", - "@chromatic-com/playwright": "^0.*.* || ^1.0.0" - }, - "peerDependenciesMeta": { - "@chromatic-com/cypress": { - "optional": true - }, - "@chromatic-com/playwright": { - "optional": true - } - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/corser": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", - "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", - "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/dedent": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", - "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/dedent-js": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz", - "integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devalue": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", - "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", - "license": "MIT" - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/dexie": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.11.tgz", - "integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", - "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-toolkit": { - "version": "1.39.7", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.7.tgz", - "integrity": "sha512-ek/wWryKouBrZIjkwW2BFf91CWOIMvoy2AE5YYgUrfWsJQM2Su1LoLtrw8uusEpN9RfqLlV/0FVNjT0WMv8Bxw==", - "dev": true, - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", - "dev": true, - "license": "MIT", - "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.2", - "@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" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-storybook": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.2.4.tgz", - "integrity": "sha512-D8a6Y+iun2MSOpgps0Vd/t8y9Y5ZZ7O2VeKqw2PCv2+b7yInqogOS2VBMSRZVfP8TTGQgDpbUK67k7KZEUC7Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/utils": "^8.48.0" - }, - "peerDependencies": { - "eslint": ">=8", - "storybook": "^10.2.4" - } - }, - "node_modules/eslint-plugin-svelte": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.15.0.tgz", - "integrity": "sha512-QKB7zqfuB8aChOfBTComgDptMf2yxiJx7FE04nneCmtQzgTHvY8UJkuh8J2Rz7KB9FFV9aTHX6r7rdYGvG8T9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.6.1", - "@jridgewell/sourcemap-codec": "^1.5.0", - "esutils": "^2.0.3", - "globals": "^16.0.0", - "known-css-properties": "^0.37.0", - "postcss": "^8.4.49", - "postcss-load-config": "^3.1.4", - "postcss-safe-parser": "^7.0.0", - "semver": "^7.6.3", - "svelte-eslint-parser": "^1.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", - "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "license": "MIT" - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrap": { - "version": "1.4.9", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.9.tgz", - "integrity": "sha512-3OMlcd0a03UGuZpPeUC1HxR3nA23l+HEyCiZw3b3FumJIN9KphoGzDJKMXI1S72jVS1dsenDyQC0kJlO1U9E1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.0.tgz", - "integrity": "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==", - "license": "MIT", - "dependencies": { - "ip-address": "10.1.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/express/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/filesize": { - "version": "10.1.6", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", - "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 10.4.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globals": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", - "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hast-util-from-dom": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", - "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "@types/hast": "^3.0.0", - "hastscript": "^9.0.0", - "web-namespaces": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-html": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", - "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "devlop": "^1.1.0", - "hast-util-from-parse5": "^8.0.0", - "parse5": "^7.0.0", - "vfile": "^6.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-html-isomorphic": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", - "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-from-dom": "^5.0.0", - "hast-util-from-html": "^2.0.0", - "unist-util-remove-position": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-html/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/hast-util-from-html/node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-html/node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-parse5": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", - "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "hastscript": "^9.0.0", - "property-information": "^7.0.0", - "vfile": "^6.0.0", - "vfile-location": "^5.0.0", - "web-namespaces": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-parse5/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/hast-util-is-element": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", - "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-sanitize": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", - "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "unist-util-position": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-html/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/hast-util-to-text": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", - "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "unist-util-find-after": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-text/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/highlight.js": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-server": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", - "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "basic-auth": "^2.0.1", - "chalk": "^4.1.2", - "corser": "^2.0.1", - "he": "^1.2.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy": "^1.18.1", - "mime": "^1.6.0", - "minimist": "^1.2.6", - "opener": "^1.5.1", - "portfinder": "^1.0.28", - "secure-compare": "3.0.1", - "union": "~0.5.0", - "url-join": "^4.0.1" - }, - "bin": { - "http-server": "bin/http-server" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", - "dev": true, - "license": "MIT" - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/inline-style-parser": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", - "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", - "license": "MIT" - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/katex": { - "version": "0.16.22", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", - "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", - "dev": true, - "funding": [ - "https://opencollective.com/katex", - "https://github.com/sponsors/katex" - ], - "license": "MIT", - "dependencies": { - "commander": "^8.3.0" - }, - "bin": { - "katex": "cli.js" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/known-css-properties": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", - "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/loupe": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz", - "integrity": "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lowlight": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", - "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "devlop": "^1.0.0", - "highlight.js": "~11.11.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/markdown-table": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", - "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdast": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mdast/-/mdast-3.0.0.tgz", - "integrity": "sha512-xySmf8g4fPKMeC07jXGz971EkLbWAJ83s4US2Tj9lEdnZ142UP5grN73H1Xd3HzrdbU5o9GYYP/y8F9ZSwLE9g==", - "dev": true, - "license": "MIT" - }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-from-markdown/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/mdast-util-from-markdown/node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", - "license": "MIT", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-math": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", - "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "longest-streak": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.1.0", - "unist-util-remove-position": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-newline-to-break": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", - "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-find-and-replace": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdsvex": { - "version": "0.12.6", - "resolved": "https://registry.npmjs.org/mdsvex/-/mdsvex-0.12.6.tgz", - "integrity": "sha512-pupx2gzWh3hDtm/iDW4WuCpljmyHbHi34r7ktOqpPGvyiM4MyfNgdJ3qMizXdgCErmvYC9Nn/qyjePy+4ss9Wg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.4", - "@types/unist": "^2.0.3", - "prism-svelte": "^0.4.7", - "prismjs": "^1.17.1", - "unist-util-visit": "^2.0.1", - "vfile-message": "^2.0.4" - }, - "peerDependencies": { - "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0-next.120" - } - }, - "node_modules/mdsvex/node_modules/unist-util-is": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", - "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdsvex/node_modules/unist-util-visit": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", - "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^4.0.0", - "unist-util-visit-parents": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdsvex/node_modules/unist-util-visit-parents": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", - "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "license": "MIT", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", - "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", - "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", - "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-math": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", - "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/katex": "^0.16.0", - "devlop": "^1.0.0", - "katex": "^0.16.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/mini-svg-data-uri": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", - "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", - "dev": true, - "license": "MIT", - "bin": { - "mini-svg-data-uri": "cli.js" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mode-watcher": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz", - "integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==", - "license": "MIT", - "dependencies": { - "runed": "^0.25.0", - "svelte-toolbelt": "^0.7.1" - }, - "peerDependencies": { - "svelte": "^5.27.0" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/opener": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", - "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", - "dev": true, - "license": "(WTFPL OR MIT)", - "bin": { - "opener": "bin/opener-bin.js" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "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" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/pdfjs-dist": { - "version": "5.4.54", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.54.tgz", - "integrity": "sha512-TBAiTfQw89gU/Z4LW98Vahzd2/LoCFprVGvGbTgFt+QCB1F+woyOPmNNVgLa6djX9Z9GGTnj7qE1UzpOVJiINw==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.16.0 || >=22.3.0" - }, - "optionalDependencies": { - "@napi-rs/canvas": "^0.1.74" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/playwright": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.56.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/portfinder": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", - "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", - "dev": true, - "license": "MIT", - "dependencies": { - "async": "^3.2.6", - "debug": "^4.3.6" - }, - "engines": { - "node": ">= 10.12" - } - }, - "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-load-config": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", - "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lilconfig": "^2.0.5", - "yaml": "^1.10.2" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-load-config/node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/postcss-safe-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", - "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/postcss-scss": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", - "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss-scss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.4.29" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-plugin-svelte": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.0.tgz", - "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "prettier": "^3.0.0", - "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" - } - }, - "node_modules/prettier-plugin-tailwindcss": { - "version": "0.6.14", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", - "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.21.3" - }, - "peerDependencies": { - "@ianvs/prettier-plugin-sort-imports": "*", - "@prettier/plugin-hermes": "*", - "@prettier/plugin-oxc": "*", - "@prettier/plugin-pug": "*", - "@shopify/prettier-plugin-liquid": "*", - "@trivago/prettier-plugin-sort-imports": "*", - "@zackad/prettier-plugin-twig": "*", - "prettier": "^3.0", - "prettier-plugin-astro": "*", - "prettier-plugin-css-order": "*", - "prettier-plugin-import-sort": "*", - "prettier-plugin-jsdoc": "*", - "prettier-plugin-marko": "*", - "prettier-plugin-multiline-arrays": "*", - "prettier-plugin-organize-attributes": "*", - "prettier-plugin-organize-imports": "*", - "prettier-plugin-sort-imports": "*", - "prettier-plugin-style-order": "*", - "prettier-plugin-svelte": "*" - }, - "peerDependenciesMeta": { - "@ianvs/prettier-plugin-sort-imports": { - "optional": true - }, - "@prettier/plugin-hermes": { - "optional": true - }, - "@prettier/plugin-oxc": { - "optional": true - }, - "@prettier/plugin-pug": { - "optional": true - }, - "@shopify/prettier-plugin-liquid": { - "optional": true - }, - "@trivago/prettier-plugin-sort-imports": { - "optional": true - }, - "@zackad/prettier-plugin-twig": { - "optional": true - }, - "prettier-plugin-astro": { - "optional": true - }, - "prettier-plugin-css-order": { - "optional": true - }, - "prettier-plugin-import-sort": { - "optional": true - }, - "prettier-plugin-jsdoc": { - "optional": true - }, - "prettier-plugin-marko": { - "optional": true - }, - "prettier-plugin-multiline-arrays": { - "optional": true - }, - "prettier-plugin-organize-attributes": { - "optional": true - }, - "prettier-plugin-organize-imports": { - "optional": true - }, - "prettier-plugin-sort-imports": { - "optional": true - }, - "prettier-plugin-style-order": { - "optional": true - }, - "prettier-plugin-svelte": { - "optional": true - } - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/prism-svelte": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/prism-svelte/-/prism-svelte-0.4.7.tgz", - "integrity": "sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "scheduler": "^0.26.0" - }, - "peerDependencies": { - "react": "^19.1.0" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/recast": { - "version": "0.23.11", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", - "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" - }, - "engines": { - "node": ">= 4" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/rehype-highlight": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", - "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-to-text": "^4.0.0", - "lowlight": "^3.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-katex": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", - "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/katex": "^0.16.0", - "hast-util-from-html-isomorphic": "^2.0.0", - "hast-util-to-text": "^4.0.0", - "katex": "^0.16.0", - "unist-util-visit-parents": "^6.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-stringify": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", - "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-to-html": "^9.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark": { - "version": "15.0.1", - "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", - "integrity": "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-breaks": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", - "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-newline-to-break": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-gfm": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", - "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-html": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/remark-html/-/remark-html-16.0.1.tgz", - "integrity": "sha512-B9JqA5i0qZe0Nsf49q3OXyGvyXuZFDzAP2iOFLEumymuYJITVpiH1IgsTEwTpdptDmZlMDMWeDmSawdaJIGCXQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "hast-util-sanitize": "^5.0.0", - "hast-util-to-html": "^9.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-math": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", - "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-math": "^3.0.0", - "micromark-extension-math": "^3.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/runed": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz", - "integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==", - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "dependencies": { - "esm-env": "^1.0.0" - }, - "peerDependencies": { - "svelte": "^5.7.0" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/sass": { - "version": "1.93.3", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.3.tgz", - "integrity": "sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/scule": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", - "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", - "dev": true, - "license": "MIT" - }, - "node_modules/secure-compare": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", - "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/set-cookie-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", - "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sirv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", - "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", - "dev": true, - "license": "MIT" - }, - "node_modules/storybook": { - "version": "10.3.3", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.3.3.tgz", - "integrity": "sha512-tMoRAts9EVqf+mEMPLC6z1DPyHbcPe+CV1MhLN55IKsl0HxNjvVGK44rVPSePbltPE6vIsn4bdRj6CCUt8SJwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "@storybook/icons": "^2.0.1", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/user-event": "^14.6.1", - "@vitest/expect": "3.2.4", - "@vitest/spy": "3.2.4", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", - "open": "^10.2.0", - "recast": "^0.23.5", - "semver": "^7.7.3", - "use-sync-external-store": "^1.5.0", - "ws": "^8.18.0" - }, - "bin": { - "storybook": "dist/bin/dispatcher.js" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "prettier": "^2 || ^3" - }, - "peerDependenciesMeta": { - "prettier": { - "optional": true - } - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", - "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/style-to-object": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.9.tgz", - "integrity": "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==", - "license": "MIT", - "dependencies": { - "inline-style-parser": "0.2.4" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/svelte": { - "version": "5.55.1", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz", - "integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.6.4", - "esm-env": "^1.2.1", - "esrap": "^2.2.4", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/svelte-ast-print": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/svelte-ast-print/-/svelte-ast-print-0.4.2.tgz", - "integrity": "sha512-hRHHufbJoArFmDYQKCpCvc0xUuIEfwYksvyLYEQyH+1xb5LD5sM/IthfooCdXZQtOIqXz6xm7NmaqdfwG4kh6w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/xeho91" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/xeho91" - } - ], - "license": "MIT", - "dependencies": { - "esrap": "1.2.2", - "zimmerframe": "1.1.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "svelte": "^5.0.0" - } - }, - "node_modules/svelte-ast-print/node_modules/esrap": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.2.2.tgz", - "integrity": "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15", - "@types/estree": "^1.0.1" - } - }, - "node_modules/svelte-check": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.0.tgz", - "integrity": "sha512-Iz8dFXzBNAM7XlEIsUjUGQhbEE+Pvv9odb9+0+ITTgFWZBGeJRRYqHUUglwe2EkLD5LIsQaAc4IUJyvtKuOO5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "chokidar": "^4.0.1", - "fdir": "^6.2.0", - "picocolors": "^1.0.0", - "sade": "^1.7.4" - }, - "bin": { - "svelte-check": "bin/svelte-check" - }, - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": ">=5.0.0" - } - }, - "node_modules/svelte-eslint-parser": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.1.tgz", - "integrity": "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.0.0", - "espree": "^10.0.0", - "postcss": "^8.4.49", - "postcss-scss": "^4.0.9", - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0", - "pnpm": "10.24.0" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - } - } - }, - "node_modules/svelte-eslint-parser/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/svelte-sonner": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.5.tgz", - "integrity": "sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg==", - "license": "MIT", - "dependencies": { - "runed": "^0.28.0" - }, - "peerDependencies": { - "svelte": "^5.0.0" - } - }, - "node_modules/svelte-sonner/node_modules/runed": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", - "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "license": "MIT", - "dependencies": { - "esm-env": "^1.0.0" - }, - "peerDependencies": { - "svelte": "^5.7.0" - } - }, - "node_modules/svelte-toolbelt": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz", - "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==", - "funding": [ - "https://github.com/sponsors/huntabyte" - ], - "dependencies": { - "clsx": "^2.1.1", - "runed": "^0.23.2", - "style-to-object": "^1.0.8" - }, - "engines": { - "node": ">=18", - "pnpm": ">=8.7.0" - }, - "peerDependencies": { - "svelte": "^5.0.0" - } - }, - "node_modules/svelte-toolbelt/node_modules/runed": { - "version": "0.23.4", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz", - "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==", - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "dependencies": { - "esm-env": "^1.0.0" - }, - "peerDependencies": { - "svelte": "^5.7.0" - } - }, - "node_modules/svelte/node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/svelte/node_modules/esrap": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", - "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15", - "@typescript-eslint/types": "^8.2.0" - } - }, - "node_modules/svelte/node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6" - } - }, - "node_modules/svelte2tsx": { - "version": "0.7.47", - "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.47.tgz", - "integrity": "sha512-1aw/MFKVPM96OBevJdC12do2an9t5Zwr3Va9amLgTLpJje36ibD1iIHpuqCYWUrdR9vw6g6btKGQPmsqE8ZYCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dedent-js": "^1.0.1", - "scule": "^1.3.0" - }, - "peerDependencies": { - "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", - "typescript": "^4.9.4 || ^5.0.0" - } - }, - "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", - "dev": true, - "license": "MIT" - }, - "node_modules/tailwind-merge": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", - "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwind-variants": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", - "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.x", - "pnpm": ">=7.x" - }, - "peerDependencies": { - "tailwind-merge": ">=3.0.0", - "tailwindcss": "*" - }, - "peerDependenciesMeta": { - "tailwind-merge": { - "optional": true - } - } - }, - "node_modules/tailwindcss": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", - "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", - "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/ts-dedent": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", - "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.10" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/tw-animate-css": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.5.tgz", - "integrity": "sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Wombosvideo" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", - "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unified/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/union": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", - "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", - "dev": true, - "dependencies": { - "qs": "^6.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/unist-util-find-after": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", - "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-find-after/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/unist-util-is": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", - "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/unist-util-remove-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", - "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-remove-position/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/unist-util-stringify-position": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", - "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", - "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", - "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/unist-util-visit/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/unplugin": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", - "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "acorn": "^8.15.0", - "picomatch": "^4.0.3", - "webpack-virtual-modules": "^0.6.2" - }, - "engines": { - "node": ">=18.12.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/url-join": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", - "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", - "dev": true, - "license": "MIT" - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/uuid": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", - "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-location": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", - "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-location/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/vfile-message": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", - "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile/node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/vfile/node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile/node_modules/vfile-message": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", - "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-plugin-devtools-json": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/vite-plugin-devtools-json/-/vite-plugin-devtools-json-0.2.1.tgz", - "integrity": "sha512-5aiNvf/iLTuLR1dUqoI5CLLGgeK2hd6u+tA+RIp7GUZDyAcM6ECaUEWOOtGpidbcxbkKq++KtmSqA3jhMbPwMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "uuid": "^11.1.0" - }, - "peerDependencies": { - "vite": "^2.7.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/vite-plugin-devtools-json/node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest-browser-svelte": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/vitest-browser-svelte/-/vitest-browser-svelte-0.1.0.tgz", - "integrity": "sha512-YB6ZUZZQNqU1T9NzvTEDpwpPv35Ng1NZMPBh81zDrLEdOgROGE6nJb79NWb1Eu/a8VkHifqArpOZfJfALge6xQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "^2.1.0 || ^3.0.0-0", - "svelte": ">3.0.0", - "vitest": "^2.1.0 || ^3.0.0-0" - } - }, - "node_modules/web-namespaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", - "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/webpack-virtual-modules": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", - "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zimmerframe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", - "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", - "license": "MIT" - }, - "node_modules/zod": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", - "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/tools/server/webui/package.json b/tools/server/webui/package.json deleted file mode 100644 index 2338c3840..000000000 --- a/tools/server/webui/package.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "name": "llama-ui", - "private": true, - "version": "1.0.0", - "type": "module", - "scripts": { - "dev": "bash scripts/dev.sh", - "build": "vite build && ./scripts/post-build.sh", - "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "reset": "rm -rf .svelte-kit node_modules", - "format": "prettier --write .", - "lint": "prettier --check . && eslint .", - "test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e", - "test:e2e": "playwright test", - "test:client": "vitest --project=client", - "test:unit": "vitest --project=unit", - "test:ui": "vitest --project=ui", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build", - "cleanup": "rm -rf .svelte-kit build node_modules test-results" - }, - "devDependencies": { - "@chromatic-com/storybook": "^5.0.0", - "@eslint/compat": "^1.2.5", - "@eslint/js": "^9.18.0", - "@internationalized/date": "^3.10.1", - "@lucide/svelte": "^0.515.0", - "@playwright/test": "^1.49.1", - "@storybook/addon-a11y": "^10.2.4", - "@storybook/addon-docs": "^10.2.4", - "@storybook/addon-svelte-csf": "^5.0.10", - "@storybook/addon-vitest": "^10.2.4", - "@storybook/sveltekit": "^10.2.4", - "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.48.4", - "@sveltejs/vite-plugin-svelte": "^6.2.1", - "@tailwindcss/forms": "^0.5.9", - "@tailwindcss/typography": "^0.5.15", - "@tailwindcss/vite": "^4.0.0", - "@types/node": "^24", - "@vitest/browser": "^3.2.3", - "@vitest/coverage-v8": "^3.2.3", - "bits-ui": "^2.14.4", - "clsx": "^2.1.1", - "dexie": "^4.0.11", - "eslint": "^9.18.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-storybook": "^10.2.4", - "eslint-plugin-svelte": "^3.0.0", - "globals": "^16.0.0", - "http-server": "^14.1.1", - "mdast": "^3.0.0", - "mdsvex": "^0.12.3", - "playwright": "^1.56.1", - "prettier": "^3.4.2", - "prettier-plugin-svelte": "^3.3.3", - "prettier-plugin-tailwindcss": "^0.6.11", - "rehype-katex": "^7.0.1", - "remark-math": "^6.0.0", - "sass": "^1.93.3", - "storybook": "^10.2.4", - "svelte": "^5.38.2", - "svelte-check": "^4.0.0", - "tailwind-merge": "^3.3.1", - "tailwind-variants": "^3.2.2", - "tailwindcss": "^4.0.0", - "tw-animate-css": "^1.3.5", - "typescript": "^5.0.0", - "typescript-eslint": "^8.20.0", - "unified": "^11.0.5", - "uuid": "^13.0.0", - "vite": "^7.2.2", - "vite-plugin-devtools-json": "^0.2.0", - "vitest": "^3.2.3", - "vitest-browser-svelte": "^0.1.0" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.1", - "highlight.js": "^11.11.1", - "mode-watcher": "^1.1.0", - "pdfjs-dist": "^5.4.54", - "rehype-highlight": "^7.0.2", - "rehype-stringify": "^10.0.1", - "remark": "^15.0.1", - "remark-breaks": "^4.0.0", - "remark-gfm": "^4.0.1", - "remark-html": "^16.0.1", - "remark-rehype": "^11.1.2", - "svelte-sonner": "^1.0.5", - "unist-util-visit": "^5.0.0", - "zod": "^4.2.1" - } -} diff --git a/tools/server/webui/playwright.config.ts b/tools/server/webui/playwright.config.ts deleted file mode 100644 index 26d3be535..000000000 --- a/tools/server/webui/playwright.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from '@playwright/test'; - -export default defineConfig({ - webServer: { - command: 'npm run build && http-server ../public -p 8181', - port: 8181, - timeout: 120000, - reuseExistingServer: false - }, - testDir: 'tests/e2e' -}); diff --git a/tools/server/webui/scripts/dev.sh b/tools/server/webui/scripts/dev.sh deleted file mode 100644 index 97c14b873..000000000 --- a/tools/server/webui/scripts/dev.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash - -# Development script for llama-ui -# -# This script starts the webui development servers (Storybook and Vite). -# Note: You need to start llama-server separately. -# -# Usage: -# bash scripts/dev.sh -# npm run dev - -cd ../../../ - -# Check and install git hooks if missing -check_and_install_hooks() { - local hooks_missing=false - - # Check for required hooks - if [ ! -f ".git/hooks/pre-commit" ] || [ ! -f ".git/hooks/pre-push" ] || [ ! -f ".git/hooks/post-push" ]; then - hooks_missing=true - fi - - if [ "$hooks_missing" = true ]; then - echo "🔧 Git hooks missing, installing them..." - cd tools/server/webui - if bash scripts/install-git-hooks.sh; then - echo "✅ Git hooks installed successfully" - else - echo "⚠️ Failed to install git hooks, continuing anyway..." - fi - cd ../../../ - else - echo "✅ Git hooks already installed" - fi -} - -# Install git hooks if needed -check_and_install_hooks - -# Cleanup function -cleanup() { - echo "🧹 Cleaning up..." - exit -} - -# Set up signal handlers -trap cleanup SIGINT SIGTERM - -echo "🚀 Starting development servers..." -echo "📝 Note: Make sure to start llama-server separately if needed" -cd tools/server/webui -# Use --insecure-http-parser to handle malformed HTTP responses from llama-server -# (some responses have both Content-Length and Transfer-Encoding headers) -storybook dev -p 6006 --ci & NODE_OPTIONS="--insecure-http-parser" vite dev --host 0.0.0.0 & - -# Wait for all background processes -wait diff --git a/tools/server/webui/scripts/install-git-hooks.sh b/tools/server/webui/scripts/install-git-hooks.sh deleted file mode 100755 index 8aa1014ba..000000000 --- a/tools/server/webui/scripts/install-git-hooks.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/bash - -# Script to install pre-commit hook for webui -# Pre-commit: formats, checks, and builds webui - -REPO_ROOT=$(git rev-parse --show-toplevel) -PRE_COMMIT_HOOK="$REPO_ROOT/.git/hooks/pre-commit" - -echo "Installing pre-commit hook for webui..." - -# Create the pre-commit hook -cat > "$PRE_COMMIT_HOOK" << 'EOF' -#!/bin/bash - -# Check if there are any changes in the webui directory -if git diff --cached --name-only | grep -q "^tools/server/webui/"; then - REPO_ROOT=$(git rev-parse --show-toplevel) - cd "$REPO_ROOT/tools/server/webui" - - # Check if package.json exists - if [ ! -f "package.json" ]; then - echo "Error: package.json not found in tools/server/webui" - exit 1 - fi - - echo "Formatting and checking webui code..." - - # Run the format command - npm run format - if [ $? -ne 0 ]; then - echo "Error: npm run format failed" - exit 1 - fi - - # Run the lint command - npm run lint - if [ $? -ne 0 ]; then - echo "Error: npm run lint failed" - exit 1 - fi - - # Run the check command - npm run check - if [ $? -ne 0 ]; then - echo "Error: npm run check failed" - exit 1 - fi - - echo "✅ Webui code formatted and checked successfully" - - # Build the webui - echo "Building webui..." - npm run build - if [ $? -ne 0 ]; then - echo "❌ npm run build failed" - exit 1 - fi - - echo "✅ Webui built successfully" -fi - -exit 0 -EOF - -# Make hook executable -chmod +x "$PRE_COMMIT_HOOK" - -if [ $? -eq 0 ]; then - echo "✅ Git hook installed successfully!" - echo " Pre-commit: $PRE_COMMIT_HOOK" - echo "" - echo "The hook will automatically:" - echo " • Format, lint and check webui code before commits" - echo " • Build webui" -else - echo "❌ Failed to make hook executable" - exit 1 -fi diff --git a/tools/server/webui/scripts/post-build.sh b/tools/server/webui/scripts/post-build.sh deleted file mode 100755 index 55e46d5d5..000000000 --- a/tools/server/webui/scripts/post-build.sh +++ /dev/null @@ -1,3 +0,0 @@ -rm -rf ../public/_app; -rm ../public/favicon.svg; -rm -f ../public/index.html.gz; # deprecated, but may still be generated by older versions of the build process diff --git a/tools/server/webui/scripts/vite-plugin-llama-cpp-build.ts b/tools/server/webui/scripts/vite-plugin-llama-cpp-build.ts deleted file mode 100644 index 0330a1dda..000000000 --- a/tools/server/webui/scripts/vite-plugin-llama-cpp-build.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { readFileSync, writeFileSync, existsSync, readdirSync, copyFileSync } from 'fs'; -import { resolve } from 'path'; -import type { Plugin } from 'vite'; - -const GUIDE_FOR_FRONTEND = ` - -`.trim(); - -export function llamaCppBuildPlugin(): Plugin { - return { - name: 'llamacpp:build', - apply: 'build', - closeBundle() { - // Ensure the SvelteKit adapter has finished writing to ../public - setTimeout(() => { - try { - const indexPath = resolve('../public/index.html'); - if (!existsSync(indexPath)) return; - - let content = readFileSync(indexPath, 'utf-8'); - - const faviconPath = resolve('static/favicon.svg'); - - if (existsSync(faviconPath)) { - const faviconContent = readFileSync(faviconPath, 'utf-8'); - const faviconBase64 = Buffer.from(faviconContent).toString('base64'); - const faviconDataUrl = `data:image/svg+xml;base64,${faviconBase64}`; - - content = content.replace(/href="[^"]*favicon\.svg"/g, `href="${faviconDataUrl}"`); - - console.log('✓ Inlined favicon.svg as base64 data URL'); - } - - content = content.replace(/\r/g, ''); - content = GUIDE_FOR_FRONTEND + '\n' + content; - content = content.replace(/\/_app\/immutable\/bundle\.[^"]+\.js/g, './bundle.js'); - content = content.replace( - /\/_app\/immutable\/assets\/bundle\.[^"]+\.css/g, - './bundle.css' - ); - content = content.replace(/__sveltekit_[a-z0-9]+/g, '__sveltekit__'); - - writeFileSync(indexPath, content, 'utf-8'); - console.log('✓ Updated index.html'); - - // Copy bundle.*.js -> ../public/bundle.js - const immutableDir = resolve('../public/_app/immutable'); - const bundleDir = resolve('../public/_app/immutable/assets'); - - if (existsSync(immutableDir)) { - const jsFiles = readdirSync(immutableDir).filter((f) => f.match(/^bundle\..+\.js$/)); - - if (jsFiles.length > 0) { - copyFileSync(resolve(immutableDir, jsFiles[0]), resolve('../public/bundle.js')); - // Normalize __sveltekit_ to __sveltekit__ in bundle.js - const bundleJsPath = resolve('../public/bundle.js'); - let bundleJs = readFileSync(bundleJsPath, 'utf-8'); - bundleJs = bundleJs.replace(/__sveltekit_[a-z0-9]+/g, '__sveltekit__'); - writeFileSync(bundleJsPath, bundleJs, 'utf-8'); - console.log(`✓ Copied ${jsFiles[0]} -> bundle.js`); - } - } - - // Copy bundle.*.css -> ../public/bundle.css - if (existsSync(bundleDir)) { - const cssFiles = readdirSync(bundleDir).filter((f) => f.match(/^bundle\..+\.css$/)); - - if (cssFiles.length > 0) { - copyFileSync(resolve(bundleDir, cssFiles[0]), resolve('../public/bundle.css')); - console.log(`✓ Copied ${cssFiles[0]} -> bundle.css`); - } - } - } catch (error) { - console.error('Failed to update index.html:', error); - } - }, 100); - } - }; -} diff --git a/tools/server/webui/src/app.css b/tools/server/webui/src/app.css deleted file mode 100644 index 6e29b70a3..000000000 --- a/tools/server/webui/src/app.css +++ /dev/null @@ -1,186 +0,0 @@ -@import 'tailwindcss'; - -@import 'tw-animate-css'; - -@custom-variant dark (&:is(.dark *)); - -:root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.95 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.95 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.875 0 0); - --input: oklch(0.92 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); - --code-background: oklch(0.985 0 0); - --code-foreground: oklch(0.145 0 0); - --layer-popover: 1000000; - - --chat-form-area-height: 8rem; - --chat-form-area-offset: 2rem; - --max-message-height: max(24rem, min(80dvh, calc(100dvh - var(--chat-form-area-height) - 12rem))); -} - -@media (min-width: 640px) { - :root { - --chat-form-area-height: 24rem; - --chat-form-area-offset: 12rem; - } -} - -.dark { - --background: oklch(0.16 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.29 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 30%); - --input: oklch(1 0 0 / 30%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.2 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); - --code-background: oklch(0.225 0 0); - --code-foreground: oklch(0.875 0 0); -} - -@theme inline { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); -} - -@layer base { - * { - @apply border-border outline-ring/50; - } - - body { - @apply bg-background text-foreground; - scrollbar-width: thin; - scrollbar-gutter: stable; - } - - /* Global scrollbar styling - visible only on hover */ - * { - scrollbar-width: thin; - scrollbar-color: transparent transparent; - transition: scrollbar-color 0.2s ease; - } - - *:hover { - scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent; - } - - *::-webkit-scrollbar { - width: 6px; - height: 6px; - } - - *::-webkit-scrollbar-track { - background: transparent; - } - - *::-webkit-scrollbar-thumb { - background: transparent; - border-radius: 3px; - transition: background 0.2s ease; - } - - *:hover::-webkit-scrollbar-thumb { - background: hsl(var(--muted-foreground) / 0.3); - } - - *::-webkit-scrollbar-thumb:hover { - background: hsl(var(--muted-foreground) / 0.5); - } -} - -@layer utilities { - .scrollbar-hide { - /* Hide scrollbar for Chrome, Safari and Opera */ - &::-webkit-scrollbar { - display: none; - } - /* Hide scrollbar for IE, Edge and Firefox */ - -ms-overflow-style: none; - scrollbar-width: none; - } -} diff --git a/tools/server/webui/src/app.d.ts b/tools/server/webui/src/app.d.ts deleted file mode 100644 index f5af7323c..000000000 --- a/tools/server/webui/src/app.d.ts +++ /dev/null @@ -1,131 +0,0 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces - -// Import chat types from dedicated module - -import type { - // API types - ApiChatCompletionRequest, - ApiChatCompletionResponse, - ApiChatCompletionStreamChunk, - ApiChatCompletionToolCall, - ApiChatCompletionToolCallDelta, - ApiChatMessageData, - ApiChatMessageContentPart, - ApiContextSizeError, - ApiErrorResponse, - ApiLlamaCppServerProps, - ApiModelDataEntry, - ApiModelListResponse, - ApiProcessingState, - ApiRouterModelMeta, - ApiRouterModelsLoadRequest, - ApiRouterModelsLoadResponse, - ApiRouterModelsStatusRequest, - ApiRouterModelsStatusResponse, - ApiRouterModelsListResponse, - ApiRouterModelsUnloadRequest, - ApiRouterModelsUnloadResponse, - // Chat types - ChatAttachmentDisplayItem, - ChatMessageType, - ChatRole, - ChatUploadedFile, - ChatMessageSiblingInfo, - ChatMessagePromptProgress, - ChatMessageTimings, - // Database types - DatabaseConversation, - DatabaseMessage, - DatabaseMessageExtra, - DatabaseMessageExtraAudioFile, - DatabaseMessageExtraImageFile, - DatabaseMessageExtraTextFile, - DatabaseMessageExtraPdfFile, - DatabaseMessageExtraLegacyContext, - ExportedConversation, - ExportedConversations, - // Model types - ModelModalities, - ModelOption, - // Settings types - SettingsChatServiceOptions, - SettingsConfigValue, - SettingsFieldConfig, - SettingsConfigType -} from '$lib/types'; - -import { ServerRole, ServerModelStatus, ModelModality } from '$lib/enums'; - -declare global { - // namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} - // } - - export { - // API types - ApiChatCompletionRequest, - ApiChatCompletionResponse, - ApiChatCompletionStreamChunk, - ApiChatCompletionToolCall, - ApiChatCompletionToolCallDelta, - ApiChatMessageData, - ApiChatMessageContentPart, - ApiContextSizeError, - ApiErrorResponse, - ApiLlamaCppServerProps, - ApiModelDataEntry, - ApiModelListResponse, - ApiProcessingState, - ApiRouterModelMeta, - ApiRouterModelsLoadRequest, - ApiRouterModelsLoadResponse, - ApiRouterModelsStatusRequest, - ApiRouterModelsStatusResponse, - ApiRouterModelsListResponse, - ApiRouterModelsUnloadRequest, - ApiRouterModelsUnloadResponse, - // Chat types - ChatAttachmentDisplayItem, - ChatMessagePromptProgress, - ChatMessageSiblingInfo, - ChatMessageTimings, - ChatMessageType, - ChatRole, - ChatUploadedFile, - // Database types - DatabaseConversation, - DatabaseMessage, - DatabaseMessageExtra, - DatabaseMessageExtraAudioFile, - DatabaseMessageExtraImageFile, - DatabaseMessageExtraTextFile, - DatabaseMessageExtraPdfFile, - DatabaseMessageExtraLegacyContext, - ExportedConversation, - ExportedConversations, - // Enum types - ModelModality, - ServerRole, - ServerModelStatus, - // Model types - ModelModalities, - ModelOption, - // Settings types - SettingsChatServiceOptions, - SettingsConfigValue, - SettingsFieldConfig, - SettingsConfigType - }; -} - -declare global { - interface Window { - idxThemeStyle?: number; - idxCodeBlock?: number; - } -} diff --git a/tools/server/webui/src/app.html b/tools/server/webui/src/app.html deleted file mode 100644 index 1391f8848..000000000 --- a/tools/server/webui/src/app.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - %sveltekit.head% - - -
%sveltekit.body%
- - diff --git a/tools/server/webui/src/lib/actions/fade-in-view.svelte.ts b/tools/server/webui/src/lib/actions/fade-in-view.svelte.ts deleted file mode 100644 index d93044805..000000000 --- a/tools/server/webui/src/lib/actions/fade-in-view.svelte.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { isElementInViewport } from '$lib/utils/viewport'; - -/** - * Svelte action that fades in an element when it enters the viewport. - * Uses IntersectionObserver for efficient viewport detection. - * - * If skipIfVisible is set and the element is already visible in the viewport - * when the action attaches (e.g. a markdown block promoted from unstable - * during streaming), the fade is skipped entirely to avoid a flash. - */ -export function fadeInView( - node: HTMLElement, - options: { duration?: number; y?: number; skipIfVisible?: boolean } = {} -) { - const { duration = 300, y = 0, skipIfVisible = false } = options; - - if (skipIfVisible && isElementInViewport(node)) { - return; - } - - node.style.opacity = '0'; - node.style.transform = `translateY(${y}px)`; - node.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`; - - $effect(() => { - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (entry.isIntersecting) { - requestAnimationFrame(() => { - node.style.opacity = '1'; - node.style.transform = 'translateY(0)'; - }); - observer.disconnect(); - } - } - }, - { threshold: 0.05 } - ); - - observer.observe(node); - - return () => { - observer.disconnect(); - }; - }); -} diff --git a/tools/server/webui/src/lib/components/app/SKILL.md b/tools/server/webui/src/lib/components/app/SKILL.md deleted file mode 100644 index 7453954ab..000000000 --- a/tools/server/webui/src/lib/components/app/SKILL.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: app -description: Opinionated app components building on top of ./ui primitives ---- - -- Can include business logic and state management -- Can include data fetching and caching logic -- Should use original spelling for HTML-native events and `camelCase` for custom events -- Props and markup attributes should be listed alphabetically -- Use JS Objects and Arrays for CSS classes and styles when they are dynamic -- Whenever there can be repetition in the component's markup, if it's too small to be decoupled as a separate component — use Svelte 5's `{#snippet}` + `{@render}` diff --git a/tools/server/webui/src/lib/components/app/actions/ActionIcon.svelte b/tools/server/webui/src/lib/components/app/actions/ActionIcon.svelte deleted file mode 100644 index 849b83b19..000000000 --- a/tools/server/webui/src/lib/components/app/actions/ActionIcon.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - -

{tooltip}

-
-
diff --git a/tools/server/webui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte b/tools/server/webui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte deleted file mode 100644 index 999f0cba9..000000000 --- a/tools/server/webui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - canCopy && copyToClipboard(text)} -/> diff --git a/tools/server/webui/src/lib/components/app/actions/index.ts b/tools/server/webui/src/lib/components/app/actions/index.ts deleted file mode 100644 index 4bb2a58d6..000000000 --- a/tools/server/webui/src/lib/components/app/actions/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * - * ACTIONS - * - * Small interactive components for user actions. - * - */ - -/** Styled icon button for action triggers with tooltip. */ -export { default as ActionIcon } from './ActionIcon.svelte'; - -/** Copy-to-clipboard icon button with clipboard logic. */ -export { default as ActionIconCopyToClipboard } from './ActionIconCopyToClipboard.svelte'; diff --git a/tools/server/webui/src/lib/components/app/badges/BadgeInfo.svelte b/tools/server/webui/src/lib/components/app/badges/BadgeInfo.svelte deleted file mode 100644 index 25986082b..000000000 --- a/tools/server/webui/src/lib/components/app/badges/BadgeInfo.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/tools/server/webui/src/lib/components/app/badges/BadgesModality.svelte b/tools/server/webui/src/lib/components/app/badges/BadgesModality.svelte deleted file mode 100644 index 841f1dd9f..000000000 --- a/tools/server/webui/src/lib/components/app/badges/BadgesModality.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - -{#each modalities as modality (modality)} - {#if modality === ModelModality.VISION || modality === ModelModality.AUDIO} - - {#if modality === ModelModality.VISION} - - - Vision - {:else} - - - Audio - {/if} - - {/if} -{/each} diff --git a/tools/server/webui/src/lib/components/app/badges/index.ts b/tools/server/webui/src/lib/components/app/badges/index.ts deleted file mode 100644 index f8098056f..000000000 --- a/tools/server/webui/src/lib/components/app/badges/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * - * BADGES & INDICATORS - * - * Small visual indicators for status and metadata. - * - */ - -/** Generic info badge with optional tooltip and click handler. */ -export { default as BadgeInfo } from './BadgeInfo.svelte'; - -/** Badge indicating model modality (vision, audio, tools). */ -export { default as BadgesModality } from './BadgesModality.svelte'; diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte deleted file mode 100644 index e74bd8456..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte +++ /dev/null @@ -1,119 +0,0 @@ - - -{#snippet attachmentitem(item: ChatAttachmentDisplayItem)} - openPreview(i, event)} - {readonly} - /> -{/snippet} - -{#if displayItems.length > 0} -
- {#if limitToSingleRow} - - {#each displayItems as item (item.id)} - {@render attachmentitem(item)} - {/each} - - {:else} -
- {#each displayItems as item (item.id)} - {@render attachmentitem(item)} - {/each} -
- {/if} -
-{/if} - - - -{#if mcpResourcePreviewExtra} - -{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte deleted file mode 100644 index 143621cd9..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - -{#if isMcpPrompt(item)} - {@const mcpPrompt = - item.attachment?.type === AttachmentType.MCP_PROMPT - ? (item.attachment as DatabaseMessageExtraMcpPrompt) - : item.uploadedFile?.mcpPrompt - ? { - type: AttachmentType.MCP_PROMPT as const, - name: item.name, - serverName: item.uploadedFile.mcpPrompt.serverName, - promptName: item.uploadedFile.mcpPrompt.promptName, - content: item.textContent ?? '', - arguments: item.uploadedFile.mcpPrompt.arguments - } - : null} - {#if mcpPrompt} - onFileRemove(item.id) : undefined} - /> - {/if} -{:else if isMcpResource(item)} - {@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource} - - onMcpResourcePreview?.(mcpResource)} - /> -{:else if item.isImage && item.preview} - onPreview?.(item)} - /> -{:else if isPdfFile(item.attachment, item.uploadedFile)} - onPreview?.(item)} - /> -{:else} - onPreview?.(item)} - /> -{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpPrompt.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpPrompt.svelte deleted file mode 100644 index 636e93f22..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpPrompt.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - -
- - - {#if !readonly && onRemove} -
- onRemove?.()} /> -
- {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpResource.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpResource.svelte deleted file mode 100644 index 6e1f639fa..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpResource.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - -
- {#if favicon} - {attachment.resource.serverName} { - (e.currentTarget as HTMLImageElement).style.display = 'none'; - }} - src={favicon} - /> - {/if} - - - {serverName} - -
-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailFile.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailFile.svelte deleted file mode 100644 index 3eeace42f..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailFile.svelte +++ /dev/null @@ -1,174 +0,0 @@ - - -{#snippet textPreview(content: string)} -
-
- {getPreviewText(content)} -
- - {#if content.length > 150} -
- {/if} -
-{/snippet} - -{#snippet removeButton()} -
- onRemove?.(id)} /> -
-{/snippet} - -{#snippet fileIcon()} -
- {fileTypeLabel} -
-{/snippet} - -{#snippet info(text: string | undefined)} - {#if text} - {text} - {/if} -{/snippet} - -{#if isTextWithContent || isPdfWithContent} - -{:else} - -{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte deleted file mode 100644 index b78a65916..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte +++ /dev/null @@ -1,65 +0,0 @@ - - -{#snippet image()} - {name} -{/snippet} - -
- {#if onclick} - - {:else} - {@render image()} - {/if} - - {#if !readonly} -
- onRemove?.(id)} - stopPropagationOnClick - tooltip="Remove" - /> -
- {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview.svelte deleted file mode 100644 index ca81e5443..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview.svelte +++ /dev/null @@ -1,190 +0,0 @@ - - -
-
- 1} /> - -
- {#if currentItem} - - - - {/if} - - -
-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItem.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItem.svelte deleted file mode 100644 index 3451c89d3..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItem.svelte +++ /dev/null @@ -1,65 +0,0 @@ - - -{#if currentItem} - {#key currentItem.id} - {#if isPdf} - - {:else if isImage} - - {:else if isText && displayTextContent} - - {:else if isAudio} - - {:else if isUnavailable} - - {/if} - {/key} -{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemAudio.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemAudio.svelte deleted file mode 100644 index 06e1f5928..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemAudio.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - -
-
- - - {#if audioSrc} - - {:else} -

Audio preview not available

- {/if} - -

{currentItem?.name || 'Audio'}

-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemImage.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemImage.svelte deleted file mode 100644 index 070ff8230..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemImage.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - -{#if displayPreview} -
- {currentItem?.name -
-{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte deleted file mode 100644 index 750532a62..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte +++ /dev/null @@ -1,174 +0,0 @@ - - -
- - - -
- -{#if !hasVisionModality && activeModelId && currentItem} - - - Preview only - - - The selected model does not support vision. Only the extracted - - - (pdfViewMode = PdfViewMode.TEXT)} - > - text - - will be sent to the model. - - - -{/if} - -{#if pdfImagesLoading} -
-
-
-

Converting PDF to images...

-
-
-{:else if pdfImagesError} -
-
- -

Failed to load PDF images

-

{pdfImagesError}

-
-
-{:else if pdfImages.length > 0} - {#each pdfImages as image, index (image)} -

Page {index + 1}

- PDF Page {index + 1} -
- {/each} -{:else} -
-
- -

No PDF pages available

-
-
-{/if} - -{#if pdfViewMode === PdfViewMode.TEXT && displayTextContent} -
- -
-{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemText.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemText.svelte deleted file mode 100644 index 5977523ac..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemText.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - -{#if displayTextContent} -
- -
-{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemUnavailable.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemUnavailable.svelte deleted file mode 100644 index d3002a939..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemUnavailable.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - -
-
- - -

Preview not available for this file type

-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte deleted file mode 100644 index d27d54a4b..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - -
-

{displayName}

- - {#if fileSize} -

{fileSize}

- {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte deleted file mode 100644 index a57e3145a..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - -{#if show} - - - -{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte deleted file mode 100644 index 4c3bd7807..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - -{#if items.length > 1} -
- - {#each items as item, index (item.id)} - - {/each} - -
-{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte deleted file mode 100644 index 46ac82334..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ /dev/null @@ -1,570 +0,0 @@ - - - - -
{ - event.preventDefault(); - - if (!canSubmit || disabled || hasLoadingAttachments) return; - - onSubmit?.(); - }} -> - - -
- - -
- { - handleInput(); - onValueChange?.(value); - }} - {disabled} - {placeholder} - /> - - {#if mcpHasResourceAttachments()} - { - preSelectedResourceUri = uri; - isResourceDialogOpen = true; - }} - /> - {/if} - - onSystemPromptClick?.({ message: value, files: uploadedFiles })} - onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined} - onMcpResourcesClick={() => (isResourceDialogOpen = true)} - /> -
-
- - - { - mcpStore.attachResource(resource.uri); - }} - onOpenChange={(newOpen: boolean) => { - if (!newOpen) { - preSelectedResourceUri = undefined; - } - }} -/> diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddButton.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddButton.svelte deleted file mode 100644 index 7175888aa..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddButton.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - -

{ATTACHMENT_TOOLTIP_TEXT}

-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte deleted file mode 100644 index 175eb3c8c..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte +++ /dev/null @@ -1,168 +0,0 @@ - - -
- - - {@render trigger({ disabled })} - - - - {#each ATTACHMENT_FILE_ITEMS as item (item.id)} - {@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)} - {#if enabled} - attachmentMenu.callbacks[item.action]()} - > - - - {item.label} - - {:else if item.disabledTooltip} - - - - - - {item.label} - - - - -

{item.disabledTooltip}

-
-
- {/if} - {/each} - - {#if !attachmentMenu.isItemEnabled('hasVisionModality')} - - - - {@const pdfItem = ATTACHMENT_FILE_ITEMS.find( - (i) => i.id === AttachmentMenuItemId.PDF - )} - {#if pdfItem} - - - {pdfItem.label} - {/if} - - - - -

PDFs will be converted to text. Image-based PDFs may not work properly.

-
-
- {/if} - - - - {#each ATTACHMENT_EXTRA_ITEMS as item (item.id)} - {#if item.id === AttachmentMenuItemId.SYSTEM_MESSAGE} - - - attachmentMenu.callbacks[item.action]()} - > - - - {item.label} - - - - -

{attachmentMenu.getSystemMessageTooltip()}

-
-
- {/if} - {/each} - - - - - - {#each ATTACHMENT_MCP_ITEMS as item (item.id)} - {#if attachmentMenu.isItemVisible(item.visibleWhen)} - attachmentMenu.callbacks[item.action]()} - > - - - {item.label} - - {/if} - {/each} -
-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte deleted file mode 100644 index dd357d6cd..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte +++ /dev/null @@ -1,150 +0,0 @@ - - - - - - - - MCP Servers - - - - {#if hasMcpServers} - -
- {#each filteredMcpServers as server (server.id)} - {@const healthState = mcpStore.getHealthCheckState(server.id)} - {@const hasError = healthState.status === HealthCheckStatus.ERROR} - {@const isEnabledForChat = isServerEnabledForChat(server.id)} - {@const displayName = getServerLabel(server)} - {@const faviconUrl = mcpStore.getServerFavicon(server.id)} - - - {/each} -
- - {#snippet footer()} - - - - Manage MCP Servers - - {/snippet} -
- {:else} -
- No MCP servers configured -
- - - - - - - Add MCP Servers - - {/if} -
-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte deleted file mode 100644 index 99daa10e3..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte +++ /dev/null @@ -1,182 +0,0 @@ - - -
- - {@render trigger({ disabled, onclick: () => (sheetOpen = true) })} - - - - - Add to chat - - - Add files, system prompt or configure MCP servers - - - -
- {#each ATTACHMENT_FILE_ITEMS as item (item.id)} - {@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)} - {#if enabled} - - {:else if item.disabledTooltip} - - - - - - -

{item.disabledTooltip}

-
-
- {/if} - {/each} - - {#if !attachmentMenu.isItemEnabled('hasVisionModality')} - {@const pdfItem = ATTACHMENT_FILE_ITEMS.find((i) => i.id === AttachmentMenuItemId.PDF)} - {#if pdfItem} - - - - - - -

PDFs will be converted to text. Image-based PDFs may not work properly.

-
-
- {/if} - {/if} - - {#each ATTACHMENT_EXTRA_ITEMS as item (item.id)} - {#if item.id === AttachmentMenuItemId.SYSTEM_MESSAGE} - - - - - - -

{attachmentMenu.getSystemMessageTooltip()}

-
-
- {/if} - {/each} - -
- - - - - MCP Servers - - - - - - Tools - - - {#each ATTACHMENT_MCP_ITEMS as item (item.id)} - {#if attachmentMenu.isItemVisible(item.visibleWhen)} - - {/if} - {/each} -
-
-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte deleted file mode 100644 index ccc35d98f..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte +++ /dev/null @@ -1,149 +0,0 @@ - - - open && toolsPanel.handleOpen()}> - - - - Tools - - - - {#if toolsPanel.totalToolCount === 0} - {#if toolsStore.loading} -
- - - Loading tools... -
- {:else if toolsStore.isToolsEndpointUnreachable} -
- - - - - Run llama-server with --tools flag to enable - - Built-in Tools. - - - - - - - - {hasMcpServersAvailable ? 'Enable' : 'Add'} MCP Server(s) to access - - MCP Tools. - - -
- {:else if toolsStore.error} -
Failed to load tools
- {:else if toolsPanel.noToolsInfoMessage} -
- - - {toolsPanel.noToolsInfoMessage} -
- {:else} -
No tools available
- {/if} - {:else} -
- {#each toolsPanel.activeGroups as group (group.label)} - {@const isExpanded = toolsPanel.expandedGroups.has(group.label)} - {@const { checked, indeterminate } = toolsPanel.getGroupCheckedState(group)} - {@const favicon = toolsPanel.getFavicon(group)} - - toolsPanel.toggleGroupExpanded(group.label)} - > -
- - {#if isExpanded} - - {:else} - - {/if} - - - {#if favicon} - { - (e.currentTarget as HTMLImageElement).style.display = 'none'; - }} - /> - {/if} - - {group.label} - - - - {toolsPanel.getEnabledToolCount(group)}/{group.tools.length} - - - - - - toolsStore.toggleGroup(group)} - class="mr-2 h-4 w-4 shrink-0" - /> - - - -

- {checked ? 'Disable' : 'Enable'} - {group.tools.length} tool{group.tools.length !== 1 ? 's' : ''} -

-
-
-
- - -
- {#each group.tools as tool (tool.function.name)} - - {/each} -
-
-
- {/each} -
- {/if} -
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte deleted file mode 100644 index 8cfd7d809..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte +++ /dev/null @@ -1,68 +0,0 @@ - - -{#if isMobile.current} - - {#snippet trigger({ disabled, onclick })} - - {/snippet} - -{:else} - - {#snippet trigger()} - - {/snippet} - -{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte deleted file mode 100644 index bdd84a481..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte +++ /dev/null @@ -1,160 +0,0 @@ - - -{#if isMobile.current} - -{:else} - -{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte deleted file mode 100644 index f1b084906..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - -
- - - - - - {#if !hasAudioModality} - -

Current model does not support audio

-
- {/if} -
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte deleted file mode 100644 index 8774bf63a..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - -{#snippet submitButton(props = {})} - -{/snippet} - -{#if tooltipLabel} - - - {@render submitButton()} - - - -

{tooltipLabel}

-
-
-{:else} - {@render submitButton()} -{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte deleted file mode 100644 index 3945155ff..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte +++ /dev/null @@ -1,146 +0,0 @@ - - -
- {#if showAddButton} -
- goto(ROUTES.MCP_SERVERS)} - /> -
- {/if} - - {#if showModelSelector} - - {/if} - - {#if isLoading && !canSubmit} - - {:else if shouldShowRecordButton} - - {:else} - - {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte deleted file mode 100644 index 395ecb201..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormMcpResourcesList.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormMcpResourcesList.svelte deleted file mode 100644 index 36c82224a..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormMcpResourcesList.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -{#if hasAttachments} -
- - {#each attachments as attachment, i (attachment.id)} - handleResourceClick(attachment.resource.uri)} - /> - {/each} - -
-{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerItemHeader.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerItemHeader.svelte deleted file mode 100644 index 11ca52049..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerItemHeader.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - -
-
- {#if faviconUrl} - { - (e.currentTarget as HTMLImageElement).style.display = 'none'; - }} - /> - {/if} - - {serverLabel} -
- -
- - {title} - - - {#if titleExtra} - {@render titleExtra()} - {/if} -
- - {#if description} -

- {description} -

- {/if} - - {#if subtitle} - {@render subtitle()} - {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte deleted file mode 100644 index 6647928b2..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - - - {#if showSearchInput} -
- -
- {/if} - -
- {#if isLoading} - {#if skeleton} - {@render skeleton()} - {/if} - {:else if items.length === 0} -
{emptyMessage}
- {:else} - {#each items as itemData, index (itemKey(itemData, index))} - {@render item(itemData, index, index === selectedIndex)} - {/each} - {/if} -
- - {#if footer} - {@render footer()} - {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte deleted file mode 100644 index 4d82c6b58..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte deleted file mode 100644 index 5a2ab26fc..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - -
-
- -
-
-
-
- - -
-
- - {#if showBadge} -
- {/if} -
- - -
-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte deleted file mode 100644 index c43a002e6..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - - { - if (!open) { - onClose?.(); - } - }} -> - - - event.preventDefault()} - > - {@render children()} - - diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte deleted file mode 100644 index 567fdac47..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte +++ /dev/null @@ -1,435 +0,0 @@ - - - - {#if selectedPrompt} - {@const prompt = selectedPrompt} - {@const server = serverSettingsMap.get(prompt.serverName)} - {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName} - -
- - {#snippet titleExtra()} - {#if prompt.arguments?.length} - - {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''} - - {/if} - {/snippet} - - - -
- {:else} - prompt.serverName + ':' + prompt.name} - > - {#snippet item(prompt, index, isSelected)} - {@const server = serverSettingsMap.get(prompt.serverName)} - {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName} - - handlePromptClick(prompt)} - > - - {#snippet titleExtra()} - {#if prompt.arguments?.length} - - {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''} - - {/if} - {/snippet} - - - {/snippet} - - {#snippet skeleton()} - - {/snippet} - - {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentForm.svelte deleted file mode 100644 index 92572b895..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentForm.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - -
- {#each prompt.arguments ?? [] as arg (arg.name)} - onArgInput(arg.name, value)} - onKeydown={(e) => onArgKeydown(e, arg.name)} - onBlur={() => onArgBlur(arg.name)} - onFocus={() => onArgFocus(arg.name)} - onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)} - /> - {/each} - - {#if promptError} - - {/if} - -
- - - -
- diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentInput.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentInput.svelte deleted file mode 100644 index 638d10eef..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentInput.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - -
- - - onInput(e.currentTarget.value)} - onkeydown={onKeydown} - onblur={onBlur} - onfocus={onFocus} - placeholder={argument.description || argument.name} - required={argument.required} - autocomplete="off" - /> - - {#if isAutocompleteActive && suggestions.length > 0} -
- {#each suggestions as suggestion, i (suggestion)} - - {/each} -
- {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte deleted file mode 100644 index 1125ae8ec..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte +++ /dev/null @@ -1,237 +0,0 @@ - - - - resource.serverName + ':' + resource.uri} - > - {#snippet item(resource, index, isSelected)} - {@const server = serverSettingsMap.get(resource.serverName)} - {@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName} - - handleResourceClick(resource)} - > - - {#snippet titleExtra()} - {#if isResourceAttached(resource.uri)} - - attached - - {/if} - {/snippet} - - {#snippet subtitle()} -

- {resource.uri} -

- {/snippet} -
-
- {/snippet} - - {#snippet skeleton()} - - {/snippet} - - {#snippet footer()} - {#if onBrowse && resources.length > 3} - - {/if} - {/snippet} -
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte deleted file mode 100644 index 7c5dc85b2..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - - - - diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte deleted file mode 100644 index 72e62f319..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte +++ /dev/null @@ -1,68 +0,0 @@ - - -
- -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte deleted file mode 100644 index 4d0b302d2..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte +++ /dev/null @@ -1,395 +0,0 @@ - - -
- {#if message.role === MessageRole.SYSTEM} - - {:else if mcpPromptExtra} - - {:else if message.role === MessageRole.USER} - - {:else} - - {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte deleted file mode 100644 index b4d69b932..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte +++ /dev/null @@ -1,390 +0,0 @@ - - -
- {#if showProcessingInfoTop} -
-
- - {processingState.getPromptProgressText() ?? - processingState.getProcessingMessage() ?? - 'Processing...'} - -
-
- {/if} - - {#if editCtx.isEditing} - - {:else if message.role === MessageRole.ASSISTANT} - {#if showRawOutput} -
{rawOutputContent || ''}
- {:else} - - {/if} - {:else} -
- {messageContent} -
- {/if} - - {#if showProcessingInfoBottom} -
-
- - {processingState.getPromptProgressText() ?? - processingState.getProcessingMessage() ?? - 'Processing...'} - -
-
- {/if} - -
- {#if displayedModel} -
- {#if isRouter} - { - const status = modelsStore.getModelStatus(modelId); - - if (status !== ServerModelStatus.LOADED) { - await modelsStore.loadModel(modelId); - } - - onRegenerate(modelName); - return true; - }} - /> - {:else} - - {/if} - - {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms} - {@const agentic = message.timings.agentic} - - {:else if isLoading() && currentConfig.showMessageStats} - {@const liveStats = processingState.getLiveProcessingStats()} - {@const genStats = processingState.getLiveGenerationStats()} - {@const promptProgress = processingState.processingState?.promptProgress} - {@const isStillProcessingPrompt = - promptProgress && promptProgress.processed < promptProgress.total} - - {#if liveStats || genStats} - - {/if} - {/if} -
- {/if} -
- - {#if message.timestamp && !editCtx.isEditing} - (showRawOutput = enabled)} - /> - {/if} -
- - diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte deleted file mode 100644 index 2dcb36baf..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - -
- {#if editCtx.isEditing} - - {:else} - - - {#if message.timestamp} -
- -
- {/if} - {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPromptContent.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPromptContent.svelte deleted file mode 100644 index 3d5dec3b6..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPromptContent.svelte +++ /dev/null @@ -1,197 +0,0 @@ - - -
-
-
- - - {#if serverFavicon} - { - (e.currentTarget as HTMLImageElement).style.display = 'none'; - }} - /> - {/if} - - - - {serverDisplayName} - - - - -
- - {#if showArgBadges} -
- {#each argumentEntries as [key, value] (key)} - - - - (hoveredArgKey = key)} - onmouseleave={() => (hoveredArgKey = null)} - > - {key} - - - - - {value} - - - {/each} -
- {/if} -
- - {#if loadError} - -
- {loadError} -
-
- {:else if isLoading} - -
-
-
- -
- -
-
-
-
- {:else if hasContent} - -
- - - - {#each contentParts as part, i (i)}{#if part.argKey} (hoveredArgKey = part.argKey)} - onmouseleave={() => (hoveredArgKey = null)}>{part.text}{:else}{part.text}{/if}{/each} -
-
- {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte deleted file mode 100644 index 9d3d07a27..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte +++ /dev/null @@ -1,232 +0,0 @@ - - -
- {#if editCtx.isEditing} -
- - -
- - - -
-
- {:else} - {#if message.content.trim()} -
- -
- {/if} -
- - {#if isExpanded && showExpandButton} -
- -
- {/if} - - - - {/if} - - {#if message.timestamp} -
- -
- {/if} - {/if} - diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte deleted file mode 100644 index 96ec1ddfd..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - -
- {#if editCtx.isEditing} - - {:else} - - - {#if message.timestamp} -
- -
- {/if} - {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte deleted file mode 100644 index dabb337dd..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - -{#if attachments && attachments.length > 0} -
- -
-{/if} - -{#if content.trim()} - - {#if renderMarkdown && currentConfig.renderUserContentAsMarkdown} -
- -
- {:else} - - {content} - - {/if} -
-{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte deleted file mode 100644 index 4be582b39..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte +++ /dev/null @@ -1,69 +0,0 @@ - - -
- {#if editCtx.isEditing} - - {:else} - - -
-
-
-
- - - -
-
-
-
- {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte deleted file mode 100644 index 254031979..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - -
-
- - - {@render message()} - -
-
- {@render actions()} -
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardContinueRequest.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardContinueRequest.svelte deleted file mode 100644 index bbb1f0ac2..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardContinueRequest.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - - - {#snippet message()} - Agentic turn limit reached. Continue? - {/snippet} - - {#snippet actions()} - - - - {/snippet} - diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte deleted file mode 100644 index e466c84ee..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte +++ /dev/null @@ -1,88 +0,0 @@ - - - - {#snippet message()} - Allow use of - - {toolName} - - {#if serverLabel} - from {serverLabel} - {/if} - - ? - {/snippet} - - {#snippet actions()} - - - - - - - - - - - - - onDecision(ToolPermissionDecision.ALWAYS)}> - Always allow
{toolName}
- tool -
- {#if serverLabel} - onDecision(ToolPermissionDecision.ALWAYS_SERVER)}> - Always allow all tools from {serverLabel} - - {:else} - {@const source = toolsStore.getToolSource(toolName)} - {@const providerName = - source === ToolSource.BUILTIN - ? TOOL_SERVER_LABELS[ToolSource.BUILTIN] - : source === ToolSource.CUSTOM - ? TOOL_SERVER_LABELS[ToolSource.CUSTOM] - : 'MCP Tools'} - onDecision(ToolPermissionDecision.ALWAYS_SERVER)}> - Approve all tools from {providerName} - - {/if} -
-
- - - {/snippet} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte deleted file mode 100644 index 503a2d086..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte +++ /dev/null @@ -1,184 +0,0 @@ - - -
-
- {#if siblingInfo && siblingInfo.totalSiblings > 1} - - {/if} - -
- - - {#if onEdit} - - {/if} - - {#if role === MessageRole.ASSISTANT && onRegenerate} - onRegenerate()} /> - {/if} - - {#if role === MessageRole.ASSISTANT && onContinue} - - {/if} - - {#if onForkConversation} - - {/if} - - -
-
- - {#if showRawOutputSwitch} -
- Show raw output - onRawOutputToggle?.(checked)} - /> -
- {/if} -
- - 1 - ? `This will delete ${deletionInfo.totalCount} messages including: ${deletionInfo.userMessages} user message${deletionInfo.userMessages > 1 ? 's' : ''} and ${deletionInfo.assistantMessages} assistant response${deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.` - : 'Are you sure you want to delete this message? This action cannot be undone.'} - confirmText={deletionInfo && deletionInfo.totalCount > 1 - ? `Delete ${deletionInfo.totalCount} Messages` - : 'Delete'} - cancelText="Cancel" - variant="destructive" - icon={Trash2} - onConfirm={handleConfirmDelete} - onCancel={() => onShowDeleteDialogChange(false)} -/> - - (showForkDialog = false)} -> -
-
- - - -
- -
- { - forkIncludeAttachments = checked === true; - }} - /> - - -
-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIconsBranchingControls.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIconsBranchingControls.svelte deleted file mode 100644 index 465dcab73..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIconsBranchingControls.svelte +++ /dev/null @@ -1,49 +0,0 @@ - - -{#if siblingInfo && siblingInfo.totalSiblings > 1} - -{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte deleted file mode 100644 index e9b77ba2f..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte +++ /dev/null @@ -1,413 +0,0 @@ - - -{#snippet renderSection(section: (typeof sectionsParsed)[number], index: number)} - {#if section.type === AgenticSectionType.TEXT} -
- -
- {:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING} - {@const streamingIcon = isStreaming ? Loader2 : Loader2} - {@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} - - toggleExpanded(index, section)} - > -
-
- Arguments: - - {#if isStreaming} - - {/if} -
- {#if section.toolArgs} - - {:else if isStreaming} -
- Receiving arguments... -
- {:else} -
- Response was truncated -
- {/if} -
-
- {:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING} - {@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING} - {@const toolIcon = isPending ? Loader2 : Wrench} - {@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} - - toggleExpanded(index, section)} - > - {#if section.toolArgs && section.toolArgs !== '{}'} -
-
Arguments:
- - -
- {/if} - -
-
- Result: - - {#if isPending} - - {/if} -
- {#if isPending} -
- Waiting for result... -
- {:else if section.toolResult} -
- {#each section.parsedLines as line, i (i)} -
{line.text}
- {#if line.image} - {line.image.name} - {/if} - {/each} -
- {:else} -
No output
- {/if} -
-
- {:else if section.type === AgenticSectionType.REASONING} - toggleExpanded(index, section)} - > -
-
- {section.content} -
-
-
- {:else if section.type === AgenticSectionType.REASONING_PENDING} - {@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'} - {@const reasoningSubtitle = isStreaming ? '' : 'incomplete'} - - toggleExpanded(index, section)} - > -
-
- {section.content} -
-
-
- {/if} -{/snippet} - -
- {#if highlightTurns && turnGroups.length > 1} - {#each turnGroups as turn, turnIndex (turnIndex)} - {@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]} -
- Turn {turnIndex + 1} - {#each turn.sections as section, sIdx (turn.flatIndices[sIdx])} - {@render renderSection(section, turn.flatIndices[sIdx])} - {/each} - {#if turnStats} -
- 0 - ? buildTurnAgenticTimings(turnStats) - : undefined} - initialView={ChatMessageStatsView.GENERATION} - hideSummary - /> -
- {/if} -
- {/each} - {:else} - {#each sectionsParsed as section, index (index)} - {@render renderSection(section, index)} - {/each} - {/if} - - {#if pendingPermission && !permissionDismissed} - - {/if} - - {#if pendingContinue && !continueDismissed} - - {/if} -
- - diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte deleted file mode 100644 index 962f2a285..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte +++ /dev/null @@ -1,154 +0,0 @@ - - - - -
- -
- -
- {#if isUserMessage && editCtx.showSaveOnlyOption} -
- - - -
- {:else if isAssistantMessage} -
- - - -
- {:else} -
- {/if} - - -
- - (showDiscardDialog = false)} -/> diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte deleted file mode 100644 index 34362e026..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte +++ /dev/null @@ -1,303 +0,0 @@ - - -
-
- {#if hasPromptStats || isLive} - - - - - - -

Reading (prompt processing)

-
-
- {/if} - - - - - - -

- {isGenerationDisabled - ? 'Generation (waiting for tokens...)' - : 'Generation (token output)'} -

-
-
- - {#if hasAgenticStats} - - - - - - -

Tool calls

-
-
- - {#if !hideSummary} - - - - - - -

Agentic summary

-
-
- {/if} - {/if} -
- -
- {#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats} - - - - - - {:else if activeView === ChatMessageStatsView.TOOLS && hasAgenticStats} - - - - - - {:else if activeView === ChatMessageStatsView.SUMMARY && hasAgenticStats} - - - - - - {:else if hasPromptStats} - - - - - - {/if} -
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte deleted file mode 100644 index eea7da7b2..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -{#if tooltipLabel} - - - - {#snippet icon()} - - {/snippet} - - {value} - - - -

{tooltipLabel}

-
-
-{:else} - - {#snippet icon()} - - {/snippet} - - {value} - -{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte deleted file mode 100644 index 281e6ad0c..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte +++ /dev/null @@ -1,294 +0,0 @@ - - -
- {#each displayMessages as { message, toolMessages, isLastAssistantMessage, siblingInfo } (message.id)} - - {/each} - - {#if activeConversation() && agenticPendingSteeringMessageContent(activeConversation()!.id)} - {@const convId = activeConversation()!.id} - {@const pendingContent = agenticPendingSteeringMessageContent(convId)} - - {#if pendingContent} - chatStore.abortCurrentFlow(convId)} - onEdit={(newContent, extras) => agenticInjectSteeringMessage(convId, newContent, extras)} - onDelete={() => agenticClearSteeringMessage(convId)} - /> - {/if} - {:else if activeConversation() && chatPendingMessageContent(activeConversation()!.id)} - {@const convId = activeConversation()!.id} - {@const pendingContent = chatPendingMessageContent(convId)} - - {#if pendingContent} - chatStore.abortCurrentFlow(convId)} - onEdit={(newContent, extras) => chatInjectPendingMessage(convId, newContent, extras)} - onDelete={() => chatClearPendingMessage(convId)} - /> - {/if} - {/if} -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte deleted file mode 100644 index 1351ed0a7..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte +++ /dev/null @@ -1,482 +0,0 @@ - - -{#if isDragOver} - -{/if} - - - -{#if isServerLoading} - -{:else} -
-
- {#if !isEmpty} - { - autoScroll.enable(); - if (!autoScroll.userScrolledUp) { - autoScroll.scrollToBottom(); - } - }} - onMessagesReady={handleMessagesReady} - /> - {/if} - -
- {#if isEmpty} -
-

Hello there

- -

- {serverStore.props?.modalities?.audio - ? 'Record audio, type a message ' - : 'Type a message'} or upload files to get started -

-
- {/if} - - {#if page.params.id} - - {/if} - - {#if hasPropsError} -
- - - - Server unavailable - - - {serverError()} - -
- {/if} - -
- chatStore.stopGeneration()} - onSystemPromptAdd={handleSystemPromptAdd} - bind:uploadedFiles - /> -
-
-
-
-{/if} - - - - (showDeleteDialog = false)} -/> - - { - if (!open) { - emptyFileNames = []; - } - }} -/> - - diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte deleted file mode 100644 index ab4adb2c2..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - -
-
- - -

Attach a file

- -

Drop your files here to upload

-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte deleted file mode 100644 index aa1c0536d..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte +++ /dev/null @@ -1,126 +0,0 @@ - - -
- -
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte deleted file mode 100644 index b5979db13..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte +++ /dev/null @@ -1,120 +0,0 @@ - - -
-
- {#each processingDetails as detail (detail)} - {detail} - {/each} -
-
- - diff --git a/tools/server/webui/src/lib/components/app/chat/index.ts b/tools/server/webui/src/lib/components/app/chat/index.ts deleted file mode 100644 index 5f6597980..000000000 --- a/tools/server/webui/src/lib/components/app/chat/index.ts +++ /dev/null @@ -1,669 +0,0 @@ -/** - * - * ATTACHMENTS - * - * Components for displaying and managing different attachment types in chat messages. - * Supports two operational modes: - * - **Readonly mode**: For displaying stored attachments in sent messages (DatabaseMessageExtra[]) - * - **Editable mode**: For managing pending uploads in the input form (ChatUploadedFile[]) - * - * The attachment system uses `getAttachmentDisplayItems()` utility to normalize both - * data sources into a unified display format, enabling consistent rendering regardless - * of the attachment origin. - * - */ - -/** - * **ChatAttachmentsList** - Unified display for file attachments in chat - * - * Central component for rendering file attachments in both ChatMessage (readonly) - * and ChatForm (editable) contexts. - * - * **Architecture:** - * - Delegates rendering to specialized thumbnail components based on attachment type - * - Manages scroll state and navigation arrows for horizontal overflow - * - Integrates with DialogChatAttachmentsPreview for full-size gallery/single viewing - * - Validates vision modality support via `activeModelId` prop - * - * **Features:** - * - Horizontal scroll with smooth navigation arrows - * - Image thumbnails with lazy loading and error fallback - * - File type icons for non-image files (PDF, text, audio, etc.) - * - MCP prompt attachments with expandable content preview - * - Click-to-preview with full-size dialog and download option - * - "View All" button when `limitToSingleRow` is enabled and content overflows - * - Vision modality validation to warn about unsupported image uploads - * - Customizable thumbnail dimensions via `imageHeight`/`imageWidth` props - * - * @example - * ```svelte - * - * - * - * - * removeFile(id)} - * limitToSingleRow - * activeModelId={selectedModel} - * /> - * ``` - */ -export { default as ChatAttachmentsList } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte'; - -/** - * Renders a single attachment item based on its type (image, file, MCP prompt, or MCP resource). - * Delegates to specialized sub-components: ChatAttachmentsListItemThumbnailImage, ChatAttachmentsListItemThumbnailFile, - * ChatAttachmentsListItemMcpPrompt, or ChatAttachmentsListItemMcpResource. - */ -export { default as ChatAttachmentsListItem } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte'; - -/** - * Displays MCP Prompt attachment with expandable content preview. - * Shows server name, prompt name, and allows expanding to view full prompt arguments - * and content. Used when user selects a prompt from ChatFormPickerMcpPrompts. - */ -export { default as ChatAttachmentsListItemMcpPrompt } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpPrompt.svelte'; - -/** - * Displays a single MCP Resource attachment with icon, name, and server info. - * Shows loading/error states and supports remove action. - * Used within ChatAttachmentMcpResources for individual resource display. - */ -export { default as ChatAttachmentsListItemMcpResource } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpResource.svelte'; - -/** - * Thumbnail for non-image file attachments. Displays file type icon based on extension, - * file name (truncated), and file size. - * Handles text files, PDFs, audio, and other document types. - */ -export { default as ChatAttachmentsListItemThumbnailFile } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailFile.svelte'; - -/** - * Thumbnail for image attachments with lazy loading and error fallback. - * Displays image preview with configurable dimensions. Falls back to placeholder - * on load error. - */ -export { default as ChatAttachmentsListItemThumbnailImage } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte'; - -/** - * Unified attachment preview component for dialog display. Shows a single file - * preview without carousel, or a gallery/carousel view when multiple items exist. - * Uses ChatAttachmentPreviewSingle internally for each item's content. - */ -export { default as ChatAttachmentsPreview } from './ChatAttachments/ChatAttachmentsPreview.svelte'; -export { default as ChatAttachmentsPreviewNavButtons } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte'; -export { default as ChatAttachmentsPreviewFileInfo } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte'; -export { default as ChatAttachmentsPreviewThumbnailStrip } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte'; -export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItem.svelte'; - -/** - * - * FORM - * - * Components for the chat input area. The form handles user input, file attachments, - * audio recording, and MCP prompts & resources selection. It integrates with multiple stores: - * - `chatStore` for message submission and generation control - * - `modelsStore` for model selection and validation - * - `mcpStore` for MCP prompt browsing and loading - * - * The form exposes a public API for programmatic control from parent components - * (focus, height reset, model selector, validation). - * - */ - -/** - * **ChatForm** - Main chat input component with rich features - * - * The primary input interface for composing and sending chat messages. - * Orchestrates text input, file attachments, audio recording, and MCP prompts. - * Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing. - * - * **Architecture:** - * - Composes ChatFormTextarea, ChatFormActions, and ChatFormPickerMcpPrompts - * - Manages file upload state via `uploadedFiles` bindable prop - * - Integrates with ModelsSelectorDropdown for model selection in router mode - * - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.) - * - * **Input Handling:** - * - IME-safe Enter key handling (waits for composition end) - * - Shift+Enter for newline, Enter for submit - * - Paste handler for files and long text (> {pasteLongTextToFileLen} chars → file conversion) - * - Keyboard shortcut `/` triggers MCP prompt picker - * - * **Features:** - * - Auto-resizing textarea with placeholder - * - File upload via button dropdown (images/text/PDF), drag-drop, or paste - * - Audio recording with WAV conversion (when model supports audio) - * - MCP prompt picker with search and argument forms - * - MCP reource picker with component to list attached resources at the bottom of Chat Form - * - Model selector integration (router mode) - * - Loading state with stop button, disabled state for errors - * - * **Exported API:** - * - `focus()` - Focus the textarea programmatically - * - `resetTextareaHeight()` - Reset textarea to default height after submit - * - `openModelSelector()` - Open model selection dropdown - * - `checkModelSelected(): boolean` - Validate model selection, show error if none - * - * @example - * ```svelte - * - * ``` - */ -export { default as ChatForm } from './ChatForm/ChatForm.svelte'; - -/** - * Wrapper component for the "add to chat" button (Plus icon). - * Exposes a `button` snippet that can be used inside DropdownMenu.Trigger (desktop) - * or Sheet.Root (mobile) to maintain consistent styling while allowing - * platform-specific trigger wrappers. - */ -export { default as ChatFormActionsAdd } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte'; - -/** - * Audio recording button with real-time recording indicator. Records audio - * and converts to WAV format for upload. Only visible when the active model - * supports audio modality and setting for automatic audio input is enabled. Shows recording duration while active. - */ -export { default as ChatFormActionRecord } from './ChatForm/ChatFormActions/ChatFormActionRecord.svelte'; - -/** - * Container for chat form action buttons. Arranges file attachment, audio record, - * and submit/stop buttons in a horizontal layout. Handles conditional visibility - * based on model capabilities and loading state. - */ -export { default as ChatFormActions } from './ChatForm/ChatFormActions/ChatFormActions.svelte'; - -/** - * Submit/stop button with loading state. Shows send icon normally, transforms - * to stop icon during generation. Disabled when input is empty or form is disabled. - * Triggers onSubmit or onStop callbacks based on current state. - */ -export { default as ChatFormActionSubmit } from './ChatForm/ChatFormActions/ChatFormActionSubmit.svelte'; - -/** - * Model selector component for the chat form action bar. Renders either a dropdown - * (desktop) or bottom sheet (mobile) for selecting the conversation model in router mode. - * Exposes an `open` method for programmatically opening the selector. - */ -export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/ChatFormActionModels.svelte'; - -/** - * Dropdown submenu for managing tool permissions in the chat form. - * - * Displays a collapsible list of available tools organized by group (Built-in / JSON Schema). - * Each group can be expanded to show individual tools with checkboxes for enabling/disabling. - * Provides bulk enable/disable controls per group and shows enabled/total tool counts. - * Opens the tools panel on the server when the menu opens. - * - * Features: - * - Grouped tools with collapsible sections - * - Group favicon display (MCP server icons) - * - Per-group and per-tool toggle checkboxes - * - Loading/error states for tool discovery - * - Integration with toolsPanel for state management - * - * @example - * ```svelte - * - * ``` - */ -export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte'; - -/** - * Dropdown submenu for managing MCP servers in the chat form. - * - * Displays a searchable list of enabled MCP servers with toggle switches - * to enable/disable each server for chat. Shows server favicon, health status, - * and a "Manage MCP Servers" settings link. - * - * Features: - * - Search/filter servers by name or URL - * - Per-server toggle to enable/disable for chat - * - Health check indicator (shows "Error" badge for failed servers) - * - Server favicon display - * - Settings link to manage MCP server configuration - * - * @example - * ```svelte - * - * ``` - */ -export { default as ChatFormActionAddMcpServersSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte'; - -/** - * Hidden file input element for programmatic file selection. - */ -export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileInputInvisible.svelte'; - -/** - * Displays MCP Resource attachments as a horizontal carousel. - * Shows resource name, URI, and allows clicking to view resource content. - */ -export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte'; - -/** - * Auto-resizing textarea with IME composition support. Automatically adjusts - * height based on content. Handles IME input correctly (waits for composition - * end before processing Enter key). Exposes focus() and resetHeight() methods. - */ -export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte'; - -/** - * **ChatFormPickerMcpPrompts** - MCP prompt selection interface - * - * Floating picker for browsing and selecting MCP Server Prompts. - * Triggered by typing `/` in the chat input or choosing `MCP Prompt` option in ChatFormActionAddDropdown. - * Loads prompts from connected MCP servers and allows users to select and configure them. - * - * **Architecture:** - * - Fetches available prompts from mcpStore - * - Manages selection state and keyboard navigation internally - * - Delegates argument input to ChatFormPromptPickerArgumentForm - * - Communicates prompt loading lifecycle via callbacks - * - * **Prompt Loading Flow:** - * 1. User selects prompt → `onPromptLoadStart` called with placeholder ID - * 2. Prompt content fetched from MCP server asynchronously - * 3. On success → `onPromptLoadComplete` with full prompt data - * 4. On failure → `onPromptLoadError` with error details - * - * **Features:** - * - Search/filter prompts by name across all connected servers - * - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close) - * - Argument input forms for prompts with required parameters - * - Autocomplete suggestions for argument values - * - Loading states with skeleton placeholders - * - Server information header per prompt for visual identification - * - * **Exported API:** - * - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled - * - * @example - * ```svelte - * showPicker = false} - * onPromptLoadStart={(id, info) => addPlaceholder(id, info)} - * onPromptLoadComplete={(id, result) => replacePlaceholder(id, result)} - * onPromptLoadError={(id, error) => handleError(id, error)} - * /> - * ``` - */ -export { default as ChatFormPickerMcpPrompts } from './ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte'; - -/** - * Form for entering MCP prompt arguments. Displays input fields for each - * required argument defined by the prompt. Validates input and submits - * when all required fields are filled. Shows argument descriptions as hints. - */ -export { default as ChatFormPromptPickerArgumentForm } from './ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentForm.svelte'; - -/** - * Single argument input field with autocomplete suggestions. Fetches suggestions - * from MCP server based on argument type. Supports keyboard navigation through - * suggestions list. Used within ChatFormPromptPickerArgumentForm. - */ -export { default as ChatFormPromptPickerArgumentInput } from './ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentInput.svelte'; - -/** - * Shared popover wrapper for inline picker popovers (prompts, resources). - * Provides consistent positioning, styling, and open/close behavior. - */ -export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte'; - -/** - * Generic scrollable list for picker popovers. Provides search input, - * scroll-into-view for keyboard navigation, loading skeletons, empty state, - * and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering. - * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources. - */ -export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte'; - -/** - * Generic button wrapper for picker list items. Provides consistent styling, - * hover/selected states, and data-picker-index attribute for scroll-into-view. - * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources. - */ -export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte'; - -/** - * Generic header for picker items displaying server favicon, label, item title, - * and optional description. Accepts `titleExtra` and `subtitle` snippets for - * custom content like badges or URIs. Shared by both pickers. - */ -export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerItemHeader.svelte'; - -/** - * Generic skeleton loading placeholder for picker list items. Configurable - * title width and optional badge skeleton. Shared by both pickers. - */ -export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte'; - -/** - * **ChatFormPickerMcpResources** - MCP resource selection interface - * - * Floating picker for browsing and attaching MCP Server Resources. - * Triggered by typing `@` in the chat input. - * Loads resources from connected MCP servers and allows users to attach them to the chat context. - * - * **Features:** - * - Search/filter resources by name, title, description, or URI across all connected servers - * - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close) - * - Shows attached state for already-attached resources - * - Loading states with skeleton placeholders - * - Server information header per resource for visual identification - * - * **Exported API:** - * - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled - */ -export { default as ChatFormPickerMcpResources } from './ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte'; - -/** - * **ChatFormPickers** - Chat input picker container - * - * Container component that hosts both MCP prompt and MCP resource pickers. - * Manages shared state, keyboard navigation, and coordination between the two - * picker interfaces. Used within ChatForm for `@`-triggered pickers. - */ -export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte'; - -/** - * - * MESSAGES - * - * Components for displaying chat messages. The message system supports: - * - **Conversation branching**: Messages can have siblings (alternative versions) - * created by editing or regenerating. Users can navigate between branches. - * - **Role-based rendering**: Different layouts for user, assistant, and system messages - * - **Streaming support**: Real-time display of assistant responses as they generate - * - **Agentic workflows**: Special rendering for tool calls and reasoning blocks - * - * The branching system uses `getMessageSiblings()` utility to compute sibling info - * for each message based on the full conversation tree stored in the database. - * - */ - -/** - * **ChatMessages** - Message list container with branching support - * - * Container component that renders the list of messages in a conversation. - * Computes sibling information for each message to enable branch navigation. - * Integrates with conversationsStore for message operations. - * - * **Architecture:** - * - Fetches all conversation messages to compute sibling relationships - * - Filters system messages based on user config (`showSystemMessage`) - * - Delegates rendering to ChatMessage for each message - * - Propagates all message operations to chatStore via callbacks - * - * **Branching Logic:** - * - Uses `getMessageSiblings()` to find all messages with same parent - * - Computes `siblingInfo: { currentIndex, totalSiblings, siblingIds }` - * - Enables navigation between alternative message versions - * - * **Message Operations (delegated to chatStore):** - * - Edit with branching: Creates new message branch, preserves original - * - Edit with replacement: Modifies message in place - * - Regenerate: Creates new assistant response as sibling - * - Delete: Removes message and all descendants (cascade) - * - Continue: Appends to incomplete assistant message - * - * @example - * ```svelte - * - * ``` - */ -export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte'; - -/** - * **ChatMessage** - Single message display with actions - * - * Renders a single chat message with role-specific styling and full action - * support. Delegates to specialized components based on message role: - * ChatMessageUser, ChatMessageAssistant, or ChatMessageSystem. - * - * **Architecture:** - * - Routes to role-specific component based on `message.type` - * - Manages edit mode state and inline editing UI - * - Handles action callbacks (copy, edit, delete, regenerate) - * - Displays branching controls when message has siblings - * - * **User Messages:** - * - Shows attachments via ChatAttachments - * - Displays MCP prompts if present - * - Edit creates new branch or preserves responses - * - * **Assistant Messages:** - * - Renders content via MarkdownContent or ChatMessageAgenticContent - * - Shows model info badge (when enabled) - * - Regenerate creates sibling with optional model override - * - Continue action for incomplete responses - * - * **Features:** - * - Inline editing with file attachments support - * - Copy formatted content to clipboard - * - Delete with confirmation (shows cascade delete count) - * - Branching controls for sibling navigation - * - Statistics display (tokens, timing) - * - * @example - * ```svelte - * - * ``` - */ -export { default as ChatMessage } from './ChatMessages/ChatMessage/ChatMessage.svelte'; - -/** - * **ChatMessageAgenticContent** - Agentic workflow output display - * - * Specialized renderer for assistant messages with tool calls and reasoning. - * Derives display sections from structured message data (toolCalls, reasoningContent, - * and child tool result messages) and renders them as interactive collapsible sections. - * - * **Architecture:** - * - Uses `deriveAgenticSections()` from `$lib/utils` to build sections from structured data - * - Renders sections as CollapsibleContentBlock components - * - Handles streaming state for progressive content display - * - Falls back to MarkdownContent for plain text sections - * - * **Execution States:** - * - **Streaming**: Animated spinner, block expanded, auto-scroll enabled - * - **Pending**: Waiting indicator for queued tool calls - * - **Completed**: Static display, block collapsed by default - * - * **Features:** - * - JSON arguments syntax highlighting via SyntaxHighlightedCode - * - Tool results display with formatting - * - Plain text sections between markers rendered as markdown - * - Smart collapse defaults (expanded while streaming, collapsed when done) - * - * @example - * ```svelte - * - * ``` - */ -export { default as ChatMessageAgenticContent } from './ChatMessages/ChatMessageAgenticContent.svelte'; -export { default as ChatMessageActionCardPermissionRequest } from './ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte'; -export { default as ChatMessageActionCard } from './ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte'; -export { default as ChatMessageActionCardContinueRequest } from './ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardContinueRequest.svelte'; - -/** - * Action buttons toolbar for messages. Displays copy, edit, delete, and regenerate - * buttons based on message role. Includes branching controls when message has siblings. - * Shows delete confirmation dialog with cascade delete count. Handles raw output toggle - * for assistant messages. - */ -export { default as ChatMessageActionIcons } from './ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte'; - -/** - * Navigation controls for message siblings (conversation branches). Displays - * prev/next arrows with current position counter (e.g., "2/5"). Enables users - * to navigate between alternative versions of a message created by editing - * or regenerating. Uses `conversationsStore.navigateToSibling()` for navigation. - */ -export { default as ChatMessageActionIconsBranchingControls } from './ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIconsBranchingControls.svelte'; - -/** - * Statistics display for assistant messages. Shows token counts (prompt/completion), - * generation timing, tokens per second, and model name (when enabled in settings). - * Data sourced from message.timings stored during generation. - */ -export { default as ChatMessageStatistics } from './ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte'; -export { default as ChatMessageStatisticsBadge } from './ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte'; - -/** - * MCP prompt display in user messages. Shows when user selected an MCP prompt - * via ChatFormPickerMcpPrompts. Displays server name, prompt name, and expandable - * content preview. Stored in message.extra as DatabaseMessageExtraMcpPrompt. - */ -export { default as ChatMessageMcpPrompt } from './ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte'; - -/** - * Formatted content display for MCP prompt messages. Renders the full prompt - * content with arguments in a readable format. Used within ChatMessageMcpPrompt - * for the expanded view. - */ -export { default as ChatMessageMcpPromptContent } from './ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPromptContent.svelte'; - -/** - * Assistant message display component. Renders assistant responses with left-aligned styling. - * Supports both plain markdown content (via MarkdownContent) and agentic content with tool calls - * (via ChatMessageAgenticContent). Shows model info badge, statistics, and action buttons. - * Handles streaming state with real-time content updates. - */ -export { default as ChatMessageAssistant } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte'; - -/** - * Inline message editing form. Provides textarea for editing message content with - * attachment management. Shows save/cancel buttons and optional "Save only" button - * for editing without regenerating responses. Used within ChatMessage components - * when user enters edit mode. - */ -export { default as ChatMessageEditForm } from './ChatMessages/ChatMessageEditForm.svelte'; - -/** - * User message display component. Renders user messages with right-aligned bubble styling. - * Shows message content, attachments via ChatAttachmentsList, and MCP prompts if present. - * Supports inline editing mode with ChatMessageEditForm integration. - */ -export { default as ChatMessageUser } from './ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte'; -export { default as ChatMessageUserBubble } from './ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte'; -export { default as ChatMessageUserPending } from './ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte'; - -/** - * System message display component. Renders system messages with distinct styling. - * Visibility controlled by `showSystemMessage` config setting. - */ -export { default as ChatMessageSystem } from './ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte'; - -/** - * - * SCREEN - * - * Top-level chat interface components. ChatScreen is the main container that - * orchestrates all chat functionality. It integrates with multiple stores: - * - `chatStore` for message operations and generation control - * - `conversationsStore` for conversation management - * - `serverStore` for server connection state - * - `modelsStore` for model capabilities (vision, audio modalities) - * - * The screen handles the complete chat lifecycle from empty state to active - * conversation with streaming responses. - * - */ - -/** - * **ChatScreen** - Main chat interface container - * - * Top-level component that orchestrates the entire chat interface. Manages - * messages display, input form, file handling, auto-scroll, error dialogs, - * and server state. Used as the main content area in chat routes. - * - * **Architecture:** - * - Composes ChatMessages, ChatScreenForm, and dialogs - * - Manages auto-scroll via `createAutoScrollController()` hook - * - Handles file upload pipeline (validation → processing → state update) - * - Integrates with serverStore for loading/error/warning states - * - Tracks active model for modality validation (vision, audio) - * - * **File Upload Pipeline:** - * 1. Files received via drag-drop, paste, or file picker - * 2. Validated against supported types (`isFileTypeSupported()`) - * 3. Filtered by model modalities (`filterFilesByModalities()`) - * 4. Empty files detected and reported via DialogEmptyFileAlert - * 5. Valid files processed to ChatUploadedFile[] format - * 6. Unsupported files shown in error dialog with reasons - * - * **State Management:** - * - `isEmpty`: Shows centered welcome UI when no conversation active - * - `isCurrentConversationLoading`: Tracks generation state for current chat - * - `activeModelId`: Determines available modalities for file validation - * - `uploadedFiles`: Pending file attachments for next message - * - * **Features:** - * - Messages display with smart auto-scroll (pauses on user scroll up) - * - File drag-drop with visual overlay indicator - * - File validation with detailed error messages - * - Error dialog management (chat errors, model unavailable) - * - Server loading/error/warning states with appropriate UI - * - Conversation deletion with confirmation dialog - * - Processing info display (tokens/sec, timing) during generation - * - Keyboard shortcuts (Ctrl+Shift+Backspace to delete conversation) - * - * @example - * ```svelte - * - * - * - * - * - * ``` - */ -export { default as ChatScreen } from './ChatScreen/ChatScreen.svelte'; - -/** - * Visual overlay displayed when user drags files over the chat screen. - * Shows drop zone indicator to guide users where to release files. - * Integrated with ChatScreen's drag-drop file upload handling. - */ -export { default as ChatScreenDragOverlay } from './ChatScreen/ChatScreenDragOverlay.svelte'; - -/** - * Chat form wrapper within ChatScreen. Positions the ChatForm component at the - * bottom of the screen with proper padding and max-width constraints. Handles - * the visual container styling for the input area. - */ -export { default as ChatScreenForm } from './ChatScreen/ChatScreenForm.svelte'; - -/** - * Processing info display during generation. Shows real-time statistics: - * tokens per second, prompt/completion token counts, and elapsed time. - * Data sourced from slotsService polling during active generation. - * Only visible when `isCurrentConversationLoading` is true. - */ -export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProcessingInfo.svelte'; diff --git a/tools/server/webui/src/lib/components/app/content/CollapsibleContentBlock.svelte b/tools/server/webui/src/lib/components/app/content/CollapsibleContentBlock.svelte deleted file mode 100644 index b7297ab6b..000000000 --- a/tools/server/webui/src/lib/components/app/content/CollapsibleContentBlock.svelte +++ /dev/null @@ -1,98 +0,0 @@ - - - { - open = value; - onToggle?.(); - }} - class={className} -> - - -
- {#if IconComponent} - - {/if} - - {title} - - {#if subtitle} - {subtitle} - {/if} -
- -
- - - Toggle content -
-
- - -
- {@render children()} -
-
-
-
diff --git a/tools/server/webui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/server/webui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte deleted file mode 100644 index c1b71e451..000000000 --- a/tools/server/webui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte +++ /dev/null @@ -1,1223 +0,0 @@ - - -
- {#each renderedBlocks as block (block.id)} -
- - {@html block.html} -
- {/each} - - {#if unstableBlockHtml} -
- - {@html unstableBlockHtml} -
- {/if} - - {#if incompleteCodeBlock} -
-
- {incompleteCodeBlock.language || 'text'} - { - previewCode = code; - previewLanguage = lang; - previewDialogOpen = true; - }} - /> -
-
streamingAutoScroll.handleScroll()} - > -
{@html highlightCode(
-							incompleteCodeBlock.code,
-							incompleteCodeBlock.language || 'text'
-						)}
-
-
- {/if} -
- - - - diff --git a/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-code-blocks.ts b/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-code-blocks.ts deleted file mode 100644 index 9d9348a5f..000000000 --- a/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-code-blocks.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Rehype plugin to enhance code blocks with wrapper, header, and action buttons. - * - * Wraps
 elements with a container that includes:
- * - Language label
- * - Copy button
- * - Preview button (for HTML code blocks)
- *
- * This operates directly on the HAST tree for better performance,
- * avoiding the need to stringify and re-parse HTML.
- */
-
-import type { Plugin } from 'unified';
-import type { Root, Element, ElementContent } from 'hast';
-import { visit } from 'unist-util-visit';
-import {
-	CODE_BLOCK_SCROLL_CONTAINER_CLASS,
-	CODE_BLOCK_WRAPPER_CLASS,
-	CODE_BLOCK_HEADER_CLASS,
-	CODE_BLOCK_ACTIONS_CLASS,
-	CODE_LANGUAGE_CLASS,
-	COPY_CODE_BTN_CLASS,
-	PREVIEW_CODE_BTN_CLASS,
-	RELATIVE_CLASS
-} from '$lib/constants';
-
-declare global {
-	interface Window {
-		idxCodeBlock?: number;
-	}
-}
-
-const COPY_ICON_SVG = ``;
-
-const PREVIEW_ICON_SVG = ``;
-
-function createIconElement(svg: string): Element {
-	return {
-		type: 'element',
-		tagName: 'span',
-		properties: {},
-		children: [{ type: 'raw', value: svg } as unknown as ElementContent]
-	};
-}
-
-function createButton(className: string, title: string, iconSvg: string, codeId: string): Element {
-	return {
-		type: 'element',
-		tagName: 'button',
-		properties: {
-			className: [className],
-			'data-code-id': codeId,
-			title,
-			type: 'button'
-		},
-		children: [createIconElement(iconSvg)]
-	};
-}
-
-function createCopyButton(codeId: string): Element {
-	return createButton(COPY_CODE_BTN_CLASS, 'Copy code', COPY_ICON_SVG, codeId);
-}
-
-function createPreviewButton(codeId: string): Element {
-	return createButton(PREVIEW_CODE_BTN_CLASS, 'Preview code', PREVIEW_ICON_SVG, codeId);
-}
-
-function createHeader(language: string, codeId: string): Element {
-	const actions: Element[] = [createCopyButton(codeId)];
-
-	if (language.toLowerCase() === 'html') {
-		actions.push(createPreviewButton(codeId));
-	}
-
-	return {
-		type: 'element',
-		tagName: 'div',
-		properties: { className: [CODE_BLOCK_HEADER_CLASS] },
-		children: [
-			{
-				type: 'element',
-				tagName: 'span',
-				properties: { className: [CODE_LANGUAGE_CLASS] },
-				children: [{ type: 'text', value: language }]
-			},
-			{
-				type: 'element',
-				tagName: 'div',
-				properties: { className: [CODE_BLOCK_ACTIONS_CLASS] },
-				children: actions
-			}
-		]
-	};
-}
-
-function createScrollContainer(preElement: Element): Element {
-	return {
-		type: 'element',
-		tagName: 'div',
-		properties: { className: [CODE_BLOCK_SCROLL_CONTAINER_CLASS] },
-		children: [preElement]
-	};
-}
-
-function createWrapper(header: Element, preElement: Element): Element {
-	return {
-		type: 'element',
-		tagName: 'div',
-		properties: { className: [CODE_BLOCK_WRAPPER_CLASS, RELATIVE_CLASS] },
-		children: [header, createScrollContainer(preElement)]
-	};
-}
-
-function extractLanguage(codeElement: Element): string {
-	const className = codeElement.properties?.className;
-	if (!Array.isArray(className)) return 'text';
-
-	for (const cls of className) {
-		if (typeof cls === 'string' && cls.startsWith('language-')) {
-			return cls.replace('language-', '');
-		}
-	}
-
-	return 'text';
-}
-
-/**
- * Generates a unique code block ID using a global counter.
- */
-function generateCodeId(): string {
-	if (typeof window !== 'undefined') {
-		return `code-${(window.idxCodeBlock = (window.idxCodeBlock ?? 0) + 1)}`;
-	}
-	// Fallback for SSR - use timestamp + random
-	return `code-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
-}
-
-/**
- * Rehype plugin to enhance code blocks with wrapper, header, and action buttons.
- * This plugin wraps 
 elements with a container that includes:
- * - Language label
- * - Copy button
- * - Preview button (for HTML code blocks)
- */
-export const rehypeEnhanceCodeBlocks: Plugin<[], Root> = () => {
-	return (tree: Root) => {
-		visit(tree, 'element', (node: Element, index, parent) => {
-			if (node.tagName !== 'pre' || !parent || index === undefined) return;
-
-			const codeElement = node.children.find(
-				(child): child is Element => child.type === 'element' && child.tagName === 'code'
-			);
-
-			if (!codeElement) return;
-
-			const language = extractLanguage(codeElement);
-			const codeId = generateCodeId();
-
-			codeElement.properties = {
-				...codeElement.properties,
-				'data-code-id': codeId
-			};
-
-			const header = createHeader(language, codeId);
-			const wrapper = createWrapper(header, node);
-
-			// Replace pre with wrapper in parent
-			(parent.children as ElementContent[])[index] = wrapper;
-		});
-	};
-};
diff --git a/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-links.ts b/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-links.ts
deleted file mode 100644
index b5fbcbdaa..000000000
--- a/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-links.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * Rehype plugin to enhance links with security attributes.
- *
- * Adds target="_blank" and rel="noopener noreferrer" to all anchor elements,
- * ensuring external links open in new tabs safely.
- */
-
-import type { Plugin } from 'unified';
-import type { Root, Element } from 'hast';
-import { visit } from 'unist-util-visit';
-
-/**
- * Rehype plugin that adds security attributes to all links.
- * This plugin ensures external links open in new tabs safely by adding:
- * - target="_blank"
- * - rel="noopener noreferrer"
- */
-export const rehypeEnhanceLinks: Plugin<[], Root> = () => {
-	return (tree: Root) => {
-		visit(tree, 'element', (node: Element) => {
-			if (node.tagName !== 'a') return;
-
-			const props = node.properties ?? {};
-
-			// Only modify if href exists
-			if (!props.href) return;
-
-			props.target = '_blank';
-			props.rel = 'noopener noreferrer';
-			node.properties = props;
-		});
-	};
-};
diff --git a/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/rehype-rtl-support.ts b/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/rehype-rtl-support.ts
deleted file mode 100644
index 0a8b93ad5..000000000
--- a/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/rehype-rtl-support.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * Rehype plugin to provide comprehensive RTL support by adding dir="auto"
- * to all text-containing elements.
- *
- * This operates directly on the HAST tree, ensuring that all elements
- * (including those not in a predefined list) receive the attribute.
- */
-
-import type { Plugin } from 'unified';
-import type { Root, Element } from 'hast';
-import { visit } from 'unist-util-visit';
-
-/**
- * Rehype plugin to add dir="auto" to all elements that have children.
- * This provides bidirectional text support for mixed RTL/LTR content.
- */
-export const rehypeRtlSupport: Plugin<[], Root> = () => {
-	return (tree: Root) => {
-		visit(tree, 'element', (node: Element) => {
-			if (node.children && node.children.length > 0) {
-				node.properties = {
-					...node.properties,
-					dir: 'auto'
-				};
-			}
-		});
-	};
-};
diff --git a/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/resolve-attachment-images.ts b/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/resolve-attachment-images.ts
deleted file mode 100644
index 36e7a3192..000000000
--- a/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/resolve-attachment-images.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { Root as HastRoot } from 'hast';
-import { visit } from 'unist-util-visit';
-import type { DatabaseMessageExtra, DatabaseMessageExtraImageFile } from '$lib/types/database';
-import { AttachmentType, UrlProtocol } from '$lib/enums';
-
-/**
- * Rehype plugin to resolve attachment image sources.
- * Converts attachment names (e.g., "mcp-attachment-xxx.png") to base64 data URLs.
- */
-export function rehypeResolveAttachmentImages(options: { attachments?: DatabaseMessageExtra[] }) {
-	return (tree: HastRoot) => {
-		visit(tree, 'element', (node) => {
-			if (node.tagName === 'img' && node.properties?.src) {
-				const src = String(node.properties.src);
-
-				// Skip data URLs and external URLs
-				if (src.startsWith(UrlProtocol.DATA) || src.startsWith(UrlProtocol.HTTP)) {
-					return;
-				}
-
-				// Find matching attachment
-				const attachment = options.attachments?.find(
-					(a): a is DatabaseMessageExtraImageFile =>
-						a.type === AttachmentType.IMAGE && a.name === src
-				);
-
-				// Replace with base64 URL if found
-				if (attachment?.base64Url) {
-					node.properties.src = attachment.base64Url;
-				}
-			}
-		});
-	};
-}
diff --git a/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/table-html-restorer.ts b/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/table-html-restorer.ts
deleted file mode 100644
index bc5d03465..000000000
--- a/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/rehype/table-html-restorer.ts
+++ /dev/null
@@ -1,181 +0,0 @@
-/**
- * Rehype plugin to restore limited HTML elements inside Markdown table cells.
- *
- * ## Problem
- * The remark/rehype pipeline neutralizes inline HTML as literal text
- * (remarkLiteralHtml) so that XML/HTML snippets in LLM responses display
- * as-is instead of being rendered. This causes 
and
    markup in - * table cells to show as plain text. - * - * ## Solution - * This plugin traverses the HAST post-conversion, parses whitelisted HTML - * patterns from text nodes, and replaces them with actual HAST element nodes - * that will be rendered as real HTML. - * - * ## Supported HTML - * - `
    ` / `
    ` / `
    ` - Line breaks (inline) - * - `
    • ...
    ` - Unordered lists (block) - * - * ## Key Implementation Details - * - * ### 1. Sibling Combination (Critical) - * The Markdown pipeline may fragment content across multiple text nodes and `
    ` - * elements. For example, `
    • a
    ` might arrive as: - * - Text: `"
      "` - * - Element: `
      ` - * - Text: `"
    • a
    "` - * - * We must combine consecutive text nodes and `
    ` elements into a single string - * before attempting to parse list markup. Without this, list detection fails. - * - * ### 2. visitParents for Deep Traversal - * Table cell content may be wrapped in intermediate elements (e.g., `

    ` tags). - * Using `visitParents` instead of direct child iteration ensures we find text - * nodes at any depth within the cell. - * - * ### 3. Reference Comparison for No-Op Detection - * When checking if `
    ` expansion changed anything, we compare: - * `expanded.length !== 1 || expanded[0] !== textNode` - * - * This catches both cases: - * - Multiple nodes created (text was split) - * - Single NEW node created (original had only `
    `, now it's an element) - * - * A simple `length > 1` check would miss the single `
    ` case. - * - * ### 4. Strict List Validation - * `parseList()` rejects malformed markup by checking for garbage text between - * `

  • ` elements. This prevents creating broken DOM from partial matches like - * `
      garbage
    • a
    `. - * - * ### 5. Newline Substitution for `
    ` in Combined String - * When combining siblings, existing `
    ` elements become `\n` in the combined - * string. This allows list content to span visual lines while still being parsed - * as a single unit. - * - * @example - * // Input Markdown: - * // | Feature | Notes | - * // |---------|-------| - * // | Multi-line | First
    Second | - * // | List |
    • A
    • B
    | - * // - * // Without this plugin:
    and
      render as literal text - * // With this plugin:
      becomes line break,
        becomes actual list - */ - -import type { Plugin } from 'unified'; -import type { Element, ElementContent, Root, Text } from 'hast'; -import { visit } from 'unist-util-visit'; -import { visitParents } from 'unist-util-visit-parents'; -import { BR_PATTERN, LIST_PATTERN, LI_PATTERN } from '$lib/constants'; - -/** - * Expands text containing `
        ` tags into an array of text nodes and br elements. - */ -function expandBrTags(value: string): ElementContent[] { - const matches = [...value.matchAll(BR_PATTERN)]; - if (!matches.length) return [{ type: 'text', value } as Text]; - - const result: ElementContent[] = []; - let cursor = 0; - - for (const m of matches) { - if (m.index! > cursor) { - result.push({ type: 'text', value: value.slice(cursor, m.index) } as Text); - } - result.push({ type: 'element', tagName: 'br', properties: {}, children: [] } as Element); - cursor = m.index! + m[0].length; - } - - if (cursor < value.length) { - result.push({ type: 'text', value: value.slice(cursor) } as Text); - } - - return result; -} - -/** - * Parses a `
        • ...
        ` string into a HAST element. - * Returns null if the markup is malformed or contains unexpected content. - */ -function parseList(value: string): Element | null { - const match = value.trim().match(LIST_PATTERN); - if (!match) return null; - - const body = match[1]; - const items: ElementContent[] = []; - let cursor = 0; - - for (const liMatch of body.matchAll(LI_PATTERN)) { - // Reject if there's non-whitespace between list items - if (body.slice(cursor, liMatch.index!).trim()) return null; - - items.push({ - type: 'element', - tagName: 'li', - properties: {}, - children: expandBrTags(liMatch[1] ?? '') - } as Element); - - cursor = liMatch.index! + liMatch[0].length; - } - - // Reject if no items found or trailing garbage exists - if (!items.length || body.slice(cursor).trim()) return null; - - return { type: 'element', tagName: 'ul', properties: {}, children: items } as Element; -} - -/** - * Processes a single table cell, restoring HTML elements from text content. - */ -function processCell(cell: Element) { - visitParents(cell, 'text', (textNode: Text, ancestors) => { - const parent = ancestors[ancestors.length - 1]; - if (!parent || parent.type !== 'element') return; - - const parentEl = parent as Element; - const siblings = parentEl.children as ElementContent[]; - const startIndex = siblings.indexOf(textNode as ElementContent); - if (startIndex === -1) return; - - // Combine consecutive text nodes and
        elements into one string - let combined = ''; - let endIndex = startIndex; - - for (let i = startIndex; i < siblings.length; i++) { - const sib = siblings[i]; - if (sib.type === 'text') { - combined += (sib as Text).value; - endIndex = i; - } else if (sib.type === 'element' && (sib as Element).tagName === 'br') { - combined += '\n'; - endIndex = i; - } else { - break; - } - } - - // Try parsing as list first (replaces entire combined range) - const list = parseList(combined); - if (list) { - siblings.splice(startIndex, endIndex - startIndex + 1, list); - return; - } - - // Otherwise, just expand
        tags in this text node - const expanded = expandBrTags(textNode.value); - if (expanded.length !== 1 || expanded[0] !== textNode) { - siblings.splice(startIndex, 1, ...expanded); - } - }); -} - -export const rehypeRestoreTableHtml: Plugin<[], Root> = () => (tree) => { - visit(tree, 'element', (node: Element) => { - if (node.tagName === 'td' || node.tagName === 'th') { - processCell(node); - } - }); -}; diff --git a/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/remark/literal-html.ts b/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/remark/literal-html.ts deleted file mode 100644 index c974d8b18..000000000 --- a/tools/server/webui/src/lib/components/app/content/MarkdownContent/plugins/remark/literal-html.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { Plugin } from 'unified'; -import { visit } from 'unist-util-visit'; -import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast'; -import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants'; - -/** - * remark plugin that rewrites raw HTML nodes into plain-text equivalents. - * - * remark parses inline HTML into `html` nodes even when we do not want to render - * them. We turn each of those nodes into regular text (plus `
        ` break markers) - * so the downstream rehype pipeline escapes the characters instead of executing - * them. Leading spaces and tab characters are converted to non‑breaking spaces to - * keep indentation identical to the original author input. - */ - -function preserveIndent(line: string): string { - let index = 0; - let output = ''; - - while (index < line.length) { - const char = line[index]; - - if (char === ' ') { - output += NBSP; - index += 1; - continue; - } - - if (char === '\t') { - output += TAB_AS_SPACES; - index += 1; - continue; - } - - break; - } - - return output + line.slice(index); -} - -function createLiteralChildren(value: string): PhrasingContent[] { - const lines = value.split(LINE_BREAK); - const nodes: PhrasingContent[] = []; - - for (const [lineIndex, rawLine] of lines.entries()) { - if (lineIndex > 0) { - nodes.push({ type: 'break' } as Break as unknown as PhrasingContent); - } - - nodes.push({ - type: 'text', - value: preserveIndent(rawLine) - } as Text as unknown as PhrasingContent); - } - - if (!nodes.length) { - nodes.push({ type: 'text', value: '' } as Text as unknown as PhrasingContent); - } - - return nodes; -} - -export const remarkLiteralHtml: Plugin<[], Root> = () => { - return (tree) => { - visit(tree, 'html', (node, index, parent) => { - if (!parent || typeof index !== 'number') { - return; - } - - const replacement = createLiteralChildren(node.value); - - if (!PHRASE_PARENTS.has(parent.type as string)) { - const paragraph: Paragraph = { - type: 'paragraph', - children: replacement as Paragraph['children'], - data: { literalHtml: true } - }; - - const siblings = parent.children as unknown as Content[]; - siblings.splice(index, 1, paragraph as unknown as Content); - - if (index > 0) { - const previous = siblings[index - 1] as Paragraph | undefined; - - if ( - previous?.type === 'paragraph' && - (previous.data as { literalHtml?: boolean } | undefined)?.literalHtml - ) { - const prevChildren = previous.children as unknown as PhrasingContent[]; - - if (prevChildren.length) { - const lastChild = prevChildren[prevChildren.length - 1]; - - if (lastChild.type !== 'break') { - prevChildren.push({ - type: 'break' - } as Break as unknown as PhrasingContent); - } - } - - prevChildren.push(...(paragraph.children as unknown as PhrasingContent[])); - - siblings.splice(index, 1); - - return index; - } - } - - return index + 1; - } - - (parent.children as unknown as PhrasingContent[]).splice( - index, - 1, - ...(replacement as unknown as PhrasingContent[]) - ); - - return index + replacement.length; - }); - }; -}; diff --git a/tools/server/webui/src/lib/components/app/content/SyntaxHighlightedCode.svelte b/tools/server/webui/src/lib/components/app/content/SyntaxHighlightedCode.svelte deleted file mode 100644 index 41d59324c..000000000 --- a/tools/server/webui/src/lib/components/app/content/SyntaxHighlightedCode.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - -
        - -
        {@html highlightedHtml}
        -
        - - diff --git a/tools/server/webui/src/lib/components/app/content/index.ts b/tools/server/webui/src/lib/components/app/content/index.ts deleted file mode 100644 index e468a441e..000000000 --- a/tools/server/webui/src/lib/components/app/content/index.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * - * CONTENT RENDERING - * - * Components for rendering rich content: markdown, code, and previews. - * - */ - -/** - * **MarkdownContent** - Rich markdown renderer - * - * Renders markdown content with syntax highlighting, LaTeX math, - * tables, links, and code blocks. Optimized for streaming with - * incremental block-based rendering. - * - * **Features:** - * - GFM (GitHub Flavored Markdown): tables, task lists, strikethrough - * - LaTeX math via KaTeX (`$inline$` and `$$block$$`) - * - Syntax highlighting (highlight.js) with language detection - * - Code copy buttons with click feedback - * - External links open in new tab with security attrs - * - Image attachment resolution from message extras - * - Dark/light theme support (auto-switching) - * - Streaming-optimized incremental rendering - * - Code preview dialog for large blocks - * - * @example - * ```svelte - * - * ``` - */ -export { default as MarkdownContent } from './MarkdownContent/MarkdownContent.svelte'; - -/** - * **SyntaxHighlightedCode** - Code syntax highlighting - * - * Renders code with syntax highlighting using highlight.js. - * Supports theme switching and scrollable containers. - * - * **Features:** - * - Auto language detection with fallback - * - Dark/light theme auto-switching - * - Scrollable container with configurable max dimensions - * - Monospace font styling - * - Preserves whitespace and formatting - * - * @example - * ```svelte - * - * ``` - */ -export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte'; - -/** - * **CollapsibleContentBlock** - Expandable content card - * - * Reusable collapsible card with header, icon, and auto-scroll. - * Used for tool calls and reasoning blocks in chat messages. - * - * **Features:** - * - Collapsible content with smooth animation - * - Custom icon and title display - * - Optional subtitle/status text - * - Auto-scroll during streaming (pauses on user scroll) - * - Configurable max height with overflow scroll - * - * @example - * ```svelte - * - * {reasoningContent} - * - * ``` - */ -export { default as CollapsibleContentBlock } from './CollapsibleContentBlock.svelte'; diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte deleted file mode 100644 index 533301dfd..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte deleted file mode 100644 index ff1005313..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - {#if isTimeout} - - {:else} - - {/if} - - {title} - - - - {description} - - - -
        -

        {message}

        - - {#if contextInfo} -
        -

        - Prompt tokens: - - {contextInfo.n_prompt_tokens.toLocaleString()} -

        - - {#if contextInfo.n_ctx} -

        - Context size: - - {contextInfo.n_ctx.toLocaleString()} -

        - {/if} -
        - {/if} -
        - - - handleOpenChange(false)}>Close - -
        -
        diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogCodePreview.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogCodePreview.svelte deleted file mode 100644 index fe5d9b504..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogCodePreview.svelte +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - Close preview - - - - - - diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte deleted file mode 100644 index becc658d3..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - {#if icon} - {@const IconComponent = icon} - - - {/if} - {title} - - - - {description} - - - - {#if children} - {@render children()} - {/if} - - - {cancelText} - - {confirmText} - - - - diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte deleted file mode 100644 index 737325085..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - Select Conversations to {mode === 'export' ? 'Export' : 'Import'} - - - - {#if mode === 'export'} - Choose which conversations you want to export. Selected conversations will be downloaded - as a JSON file. - {:else} - Choose which conversations you want to import. Selected conversations will be merged - with your existing conversations. - {/if} - - - - - - - diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte deleted file mode 100644 index 4a9eccef7..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - Update Conversation Title? - - - Do you want to update the conversation title to match the first message content? - - - -
        -
        -

        Current title:

        - -

        {currentTitle}

        -
        - -
        -

        New title would be:

        - -

        {newTitle}

        -
        -
        - - - - - - -
        -
        diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte deleted file mode 100644 index f875b0aba..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - Empty Files Detected - - - - The following files are empty and have been removed from your attachments: - - - -
        -
        -
        Empty Files:
        - -
          - {#each emptyFiles as fileName (fileName)} -
        • {fileName}
        • - {/each} -
        -
        - -
        -
        What happened:
        - -
          -
        • Empty files cannot be processed or sent to the AI model
        • - -
        • These files have been automatically removed from your attachments
        • - -
        • You can try uploading files with content instead
        • -
        -
        -
        - - - handleOpenChange(false)}>Got it - -
        -
        diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogExportSettings.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogExportSettings.svelte deleted file mode 100644 index c112bde9f..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogExportSettings.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - {#if includeSensitiveData} - - {:else} - - {/if} - Export Settings - - - - {#if includeSensitiveData} -

        - Warning: This export will include sensitive data such as API keys and MCP server custom - headers (e.g., authorization tokens). Do not share this file with anyone you don't - trust. -

        - {:else} -

        - Sensitive data (API keys, MCP server custom headers) will not be included in the export - to protect your credentials. -

        - {/if} -
        -
        - -
        - - - -
        - - - Cancel - - {#if includeSensitiveData} - Export Anyway - {:else} - Export Without Sensitive Data - {/if} - - -
        -
        diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogFileUploadError.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogFileUploadError.svelte deleted file mode 100644 index 3bb2d357f..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogFileUploadError.svelte +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - File Upload Error - - - Some files cannot be uploaded with the current model. - - - -
        - {#if fileErrorData.generallyUnsupported.length > 0} -
        -

        Unsupported File Types

        - -
        - {#each fileErrorData.generallyUnsupported as file (file.name)} -
        -

        - {file.name} -

        - -

        File type not supported

        -
        - {/each} -
        -
        - {/if} - - {#if fileErrorData.modalityUnsupported.length > 0} -
        -
        - {#each fileErrorData.modalityUnsupported as file (file.name)} -
        -

        - {file.name} -

        - -

        - {fileErrorData.modalityReasons[file.name] || 'Not supported by current model'} -

        -
        - {/each} -
        -
        - {/if} -
        - -
        -

        This model supports:

        - -

        - {fileErrorData.supportedTypes.join(', ')} -

        -
        - - - handleOpenChange(false)}>Got it - -
        -
        -
        diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogMcpResourcePreview.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogMcpResourcePreview.svelte deleted file mode 100644 index 7bf284089..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogMcpResourcePreview.svelte +++ /dev/null @@ -1,122 +0,0 @@ - - - - - - {extra.name} - - -
        - {extra.uri} - - {#if serverName} - - · - {#if favicon} - { - (e.currentTarget as HTMLImageElement).style.display = 'none'; - }} - /> - {/if} - {serverName} - - {/if} - - {#if extra.mimeType} - {extra.mimeType} - {/if} -
        -
        -
        - -
        - - - -
        - -
        - {#if isImageResource(extra.mimeType, extra.uri) && extra.content} -
        - {extra.name} -
        - {:else if isCodeResource(extra.mimeType, extra.uri) && extra.content} - - {:else if extra.content} -
        {extra.content}
        - {:else} -
        No content available
        - {/if} -
        -
        -
        diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte deleted file mode 100644 index eb162a557..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte +++ /dev/null @@ -1,394 +0,0 @@ - - - - - - - - - MCP Resources - - {#if totalCount > 0} - ({totalCount}) - {/if} - - - - Browse and attach resources from connected MCP servers to your chat context. - - - -
        -
        - -
        - -
        - {#if selectedTemplate && !templatePreviewContent} -
        -
        - - - - {selectedTemplate.title || selectedTemplate.name} - -
        - - {#if selectedTemplate.description} -

        - {selectedTemplate.description} -

        - {/if} - -
        -

        - {selectedTemplate.uriTemplate} -

        -
        - - {#if templatePreviewLoading} -
        - -
        - {:else if templatePreviewError} -
        - {templatePreviewError} - - -
        - {:else} - - {/if} -
        - {:else if hasTemplateResult} - - - {:else if selectedResources.size === 1} - {@const allResources = getAllResourcesFlatInTreeOrder()} - {@const selectedResource = allResources.find((r) => selectedResources.has(r.uri))} - - - {:else if selectedResources.size > 1} -
        - {#each getAllResourcesFlatInTreeOrder() as resource (resource.uri)} - {#if selectedResources.has(resource.uri)} - - {/if} - {/each} -
        - {:else} -
        - Select a resource to preview -
        - {/if} -
        -
        - - - - - {#if hasTemplateResult} - - {:else} - - {/if} - -
        -
        diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte deleted file mode 100644 index 349f7e7fb..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - Add New Server - - -
        - (newServerUrl = v)} - onHeadersChange={(v) => (newServerHeaders = v)} - urlError={newServerUrl ? newServerUrlError : null} - id="new-server" - /> -
        - - - - - - -
        -
        diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte deleted file mode 100644 index 5a10859a0..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogModelInformation.svelte +++ /dev/null @@ -1,270 +0,0 @@ - - - - - - - - Model Information - - Current model details and capabilities - - -
        - {#if isLoadingModels || isLoadingRouterProps} -
        -
        Loading model information...
        -
        - {:else if firstModel} - {@const modelMeta = firstModel.meta} - - {#if serverProps} - - - - Model - - -
        - - {modelName} - - - -
        -
        -
        -
        - - - - File Path - - - - {serverProps.model_path} - - - - - - - - {#if serverProps?.default_generation_settings?.n_ctx} - - Context Size - - {formatNumber(serverProps.default_generation_settings.n_ctx)} tokens - - {:else} - - Context Size - - Not available - - {/if} - - - {#if modelMeta?.n_ctx_train} - - Training Context - - {formatNumber(modelMeta.n_ctx_train)} tokens - - {/if} - - - {#if modelMeta?.size} - - Model Size - - {formatFileSize(modelMeta.size)} - - {/if} - - - {#if modelMeta?.n_params} - - Parameters - - {formatParameters(modelMeta.n_params)} - - {/if} - - - {#if modelMeta?.n_embd} - - Embedding Size - - {formatNumber(modelMeta.n_embd)} - - {/if} - - - {#if modelMeta?.n_vocab} - - Vocabulary Size - - {formatNumber(modelMeta.n_vocab)} tokens - - {/if} - - - {#if modelMeta?.vocab_type} - - Vocabulary Type - {modelMeta.vocab_type} - - {/if} - - - - Parallel Slots - - {serverProps.total_slots} - - - - {#if modalities.length > 0} - - Modalities - - -
        - -
        -
        -
        - {/if} - - - - Build Info - - {serverProps.build_info} - - - - {#if serverProps.chat_template} - - Chat Template - - -
        -
        {serverProps.chat_template}
        -
        -
        -
        - {/if} -
        -
        - {/if} - {:else if !isLoadingModels} -
        -
        No model information available
        -
        - {/if} -
        -
        -
        diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte deleted file mode 100644 index a6c20291f..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - Model Not Available - - - - The requested model could not be found. Select an available model to continue. - - - -
        -
        -

        - Requested: {modelName} -

        -
        - - {#if availableModels.length > 0} -
        -

        Select an available model:

        -
        - {#each availableModels as model (model)} - - {/each} -
        -
        - {/if} -
        - - - handleOpenChange(false)}>Cancel - -
        -
        diff --git a/tools/server/webui/src/lib/components/app/dialogs/index.ts b/tools/server/webui/src/lib/components/app/dialogs/index.ts deleted file mode 100644 index 5a6453b72..000000000 --- a/tools/server/webui/src/lib/components/app/dialogs/index.ts +++ /dev/null @@ -1,476 +0,0 @@ -/** - * - * DIALOGS - * - * Modal dialog components for the chat application. - * - * All dialogs use ShadCN Dialog or AlertDialog components for consistent - * styling, accessibility, and animation. They integrate with application - * stores for state management and data access. - * - */ - -/** - * **DialogMcpServerAddNew** - Add new MCP server dialog - * - * Modal dialog for adding a new MCP server with URL and optional headers. - * Validates URL format and integrates with mcpStore and conversationsStore. - */ -export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte'; - -/** - * **DialogExportSettings** - Settings export dialog with sensitive data warning - * - * Dialog for exporting settings with an option to include or exclude - * sensitive data (API keys, MCP server custom headers). Defaults to excluding - * sensitive data for security. User must explicitly opt-in to include them. - * - * **Architecture:** - * - Uses ShadCN AlertDialog - * - Checkbox to toggle sensitive data inclusion (defaults to false) - * - Warning icon and message when sensitive data is included - * - Destructive variant for the action button when exporting with sensitive data - * - * **Features:** - * - Secure default: sensitive data excluded by default - * - User must explicitly opt-in to include sensitive data - * - Visual warning (ShieldOff icon) when sensitive data is included - * - Different action text based on sensitive data state - * - * @example - * ```svelte - * showExportSettings = false} - * /> - * ``` - */ -export { default as DialogExportSettings } from './DialogExportSettings.svelte'; - -/** - * - * CONFIRMATION DIALOGS - * - * Dialogs for user action confirmations. Use AlertDialog for blocking - * confirmations that require explicit user decision before proceeding. - * - */ - -/** - * **DialogConfirmation** - Generic confirmation dialog - * - * Reusable confirmation dialog with customizable title, description, - * and action buttons. Supports destructive action styling and custom icons. - * Used for delete confirmations, irreversible actions, and important decisions. - * - * **Architecture:** - * - Uses ShadCN AlertDialog - * - Supports variant styling (default, destructive) - * - Customizable button labels and callbacks - * - * **Features:** - * - Customizable title and description text - * - Destructive variant with red styling for dangerous actions - * - Custom icon support in header - * - Cancel and confirm button callbacks - * - Keyboard accessible (Escape to cancel, Enter to confirm) - * - * @example - * ```svelte - * showDelete = false} - * /> - * ``` - */ -export { default as DialogConfirmation } from './DialogConfirmation.svelte'; - -/** - * **DialogConversationTitleUpdate** - Conversation rename confirmation - * - * Confirmation dialog shown when editing the first user message in a conversation. - * Asks user whether to update the conversation title to match the new message content. - * - * **Architecture:** - * - Uses ShadCN AlertDialog - * - Shows current vs proposed title comparison - * - Triggered by ChatMessages when first message is edited - * - * **Features:** - * - Side-by-side display of current and new title - * - "Keep Current Title" and "Update Title" action buttons - * - Styled title previews in muted background boxes - * - * @example - * ```svelte - * showTitleUpdate = false} - * /> - * ``` - */ -export { default as DialogConversationTitleUpdate } from './DialogConversationTitleUpdate.svelte'; - -/** - * - * CONTENT PREVIEW DIALOGS - * - * Dialogs for previewing and displaying content in full-screen or modal views. - * - */ - -/** - * **DialogCodePreview** - Full-screen code/HTML preview - * - * Full-screen dialog for previewing HTML or code in an isolated iframe. - * Used by MarkdownContent component for previewing rendered HTML blocks - * from code blocks in chat messages. - * - * **Architecture:** - * - Uses ShadCN Dialog with full viewport layout - * - Sandboxed iframe execution (allow-scripts only) - * - Clears content when closed for security - * - * **Features:** - * - Full viewport iframe preview - * - Sandboxed execution environment - * - Close button with mix-blend-difference for visibility over any content - * - Automatic content cleanup on close - * - Supports HTML preview with proper isolation - * - * @example - * ```svelte - * - * ``` - */ -export { default as DialogCodePreview } from './DialogCodePreview.svelte'; - -/** - * - * ATTACHMENT DIALOGS - * - * Dialogs for viewing and managing file attachments. Support both - * uploaded files (pending) and stored attachments (in messages). - * - */ - -/** - * **DialogChatAttachmentsPreview** - Unified attachment preview dialog - * - * Modal dialog for previewing file attachments. Automatically adapts to the - * number of items: shows a single file preview without carousel for one item, - * or a gallery with carousel navigation for multiple items. - * - * **Architecture:** - * - Wraps ChatAttachmentsPreview component in ShadCN Dialog - * - Accepts uploadedFiles and attachments arrays as data sources - * - Filters out MCP prompts and MCP resources from display - * - * **Features:** - * - Single item mode: direct preview without navigation controls - * - Multi-item mode: gallery with left/right arrows and thumbnail strip - * - File type aware preview (images, text, PDFs, audio) - * - File name and size/count display in header - * - * @example - * ```svelte - * - * - * ``` - */ -export { default as DialogChatAttachmentsPreview } from './DialogChatAttachmentsPreview.svelte'; - -/** - * - * ERROR & ALERT DIALOGS - * - * Dialogs for displaying errors, warnings, and alerts to users. - * Provide context about what went wrong and recovery options. - * - */ - -/** - * **DialogChatError** - Chat/generation error display - * - * Alert dialog for displaying chat and generation errors with context - * information. Supports different error types with appropriate styling - * and messaging. - * - * **Architecture:** - * - Uses ShadCN AlertDialog for modal display - * - Differentiates between timeout and server errors - * - Shows context info when available (token counts) - * - * **Error Types:** - * - **timeout**: TCP timeout with timer icon, red destructive styling - * - **server**: Server error with warning icon, amber warning styling - * - * **Features:** - * - Type-specific icons (TimerOff for timeout, AlertTriangle for server) - * - Error message display in styled badge - * - Context info showing prompt tokens and context size - * - Close button to dismiss - * - * @example - * ```svelte - * - * ``` - */ -export { default as DialogChatError } from './DialogChatError.svelte'; - -/** - * **DialogEmptyFileAlert** - Empty file upload warning - * - * Alert dialog shown when user attempts to upload empty files. Lists the - * empty files that were detected and removed from attachments, with - * explanation of why empty files cannot be processed. - * - * **Architecture:** - * - Uses ShadCN AlertDialog for modal display - * - Receives list of empty file names from ChatScreen - * - Triggered during file upload validation - * - * **Features:** - * - FileX icon indicating file error - * - List of empty file names in monospace font - * - Explanation of what happened and why - * - Single "Got it" dismiss button - * - * @example - * ```svelte - * - * ``` - */ -export { default as DialogEmptyFileAlert } from './DialogEmptyFileAlert.svelte'; - -/** - * **DialogFileUploadError** - File upload compatibility error - * - * Alert dialog shown when files cannot be uploaded due to type incompatibility - * or model modality restrictions. Displays a categorized list of problematic - * files with explanations and shows which file types the current model supports. - * - * **Architecture:** - * - Uses ShadCN AlertDialog for modal display - * - Receives structured file error data from ChatScreen - * - Triggered during file upload validation in processFiles() - * - * **Features:** - * - Categorized display: unsupported types vs modality restrictions - * - File name in monospace with contextual error messages - * - Summary of supported file types for the current model - * - Scrollable content area for large error lists - * - Single "Got it" dismiss button - * - * @example - * ```svelte - * - * ``` - */ -export { default as DialogFileUploadError } from './DialogFileUploadError.svelte'; - -/** - * **DialogModelNotAvailable** - Model unavailable error - * - * Alert dialog shown when the requested model (from URL params or selection) - * is not available on the server. Displays the requested model name and - * offers selection from available models. - * - * **Architecture:** - * - Uses ShadCN AlertDialog for modal display - * - Integrates with SvelteKit navigation for model switching - * - Receives available models list from modelsStore - * - * **Features:** - * - Warning icon with amber styling - * - Requested model name display in styled badge - * - Scrollable list of available models - * - Click model to navigate with updated URL params - * - Cancel button to dismiss without selection - * - * @example - * ```svelte - * - * ``` - */ -export { default as DialogModelNotAvailable } from './DialogModelNotAvailable.svelte'; - -/** - * - * DATA MANAGEMENT DIALOGS - * - * Dialogs for managing conversation data, including import/export - * and selection operations. - * - */ - -/** - * **DialogConversationSelection** - Conversation picker for import/export - * - * Dialog for selecting conversations during import or export operations. - * Displays list of conversations with checkboxes for multi-selection. - * Used by ChatSettingsImportExportTab for data management. - * - * **Architecture:** - * - Wraps ConversationSelection component in ShadCN Dialog - * - Supports export mode (select from local) and import mode (select from file) - * - Resets selection state when dialog opens - * - High z-index to appear above settings dialog - * - * **Features:** - * - Multi-select with checkboxes - * - Conversation title and message count display - * - Select all / deselect all controls - * - Mode-specific descriptions (export vs import) - * - Cancel and confirm callbacks with selected conversations - * - * @example - * ```svelte - * showExportSelection = false} - * /> - * ``` - */ -export { default as DialogConversationSelection } from './DialogConversationSelection.svelte'; - -/** - * - * MODEL INFORMATION DIALOGS - * - * Dialogs for displaying model and server information. - * - */ - -/** - * **DialogModelInformation** - Model details display - * - * Dialog showing comprehensive information about the currently loaded model - * and server configuration. Displays model metadata, capabilities, and - * server settings in a structured table format. - * - * **Architecture:** - * - Uses ShadCN Dialog with wide layout for table display - * - Fetches data from serverStore (props) and modelsStore (metadata) - * - Auto-fetches models when dialog opens if not loaded - * - * **Information Displayed:** - * - **Model**: Name with copy button - * - **File Path**: Full path to model file with copy button - * - **Context Size**: Current context window size - * - **Training Context**: Original training context (if available) - * - **Model Size**: File size in human-readable format - * - **Parameters**: Parameter count (e.g., "7B", "70B") - * - **Embedding Size**: Embedding dimension - * - **Vocabulary Size**: Token vocabulary size - * - **Vocabulary Type**: Tokenizer type (BPE, etc.) - * - **Parallel Slots**: Number of concurrent request slots - * - **Modalities**: Supported input types (text, vision, audio) - * - **Build Info**: Server build information - * - **Chat Template**: Full Jinja template in scrollable code block - * - * **Features:** - * - Copy buttons for model name and path - * - Modality badges with icons - * - Responsive table layout with container queries - * - Loading state while fetching model info - * - Scrollable chat template display - * - * @example - * ```svelte - * - * ``` - */ -export { default as DialogModelInformation } from './DialogModelInformation.svelte'; - -/** - * **DialogMcpResourcesBrowser** - MCP resources browser dialog - * - * Dialog for browsing and attaching MCP resources to chat context. - * Displays resources from connected MCP servers in a tree structure - * with preview panel and multi-select support. - * - * **Architecture:** - * - Uses ShadCN Dialog with two-panel layout - * - Left panel: McpResourcesBrowser with tree navigation - * - Right panel: McpResourcePreview for selected resource - * - Integrates with mcpStore for resource fetching and attachment - * - * **Features:** - * - Tree-based resource navigation by server and path - * - Single and multi-select with shift+click - * - Resource preview with content display - * - Quick attach button per resource - * - Batch attach for multiple selections - * - * @example - * ```svelte - * - * ``` - */ -export { default as DialogMcpResourcesBrowser } from './DialogMcpResourcesBrowser.svelte'; - -/** - * **DialogMcpResourcePreview** - MCP resource content preview - * - * Dialog for previewing the content of a stored MCP resource attachment. - * Displays the resource content with syntax highlighting for code, - * image rendering for images, and plain text for other content. - * - * **Features:** - * - Syntax highlighted code preview - * - Image rendering for image resources - * - Copy to clipboard and download actions - * - Server name and favicon display - * - MIME type badge - * - * @example - * ```svelte - * - * ``` - */ -export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.svelte'; diff --git a/tools/server/webui/src/lib/components/app/forms/InputWithSuggestions.svelte b/tools/server/webui/src/lib/components/app/forms/InputWithSuggestions.svelte deleted file mode 100644 index 5d047c59a..000000000 --- a/tools/server/webui/src/lib/components/app/forms/InputWithSuggestions.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - -
        - - - onInput(e.currentTarget.value)} - onkeydown={onKeydown} - onblur={onBlur} - onfocus={onFocus} - placeholder="Enter {name}" - autocomplete="off" - /> - - {#if isAutocompleteActive && suggestions.length > 0} -
        - {#each suggestions as suggestion, i (suggestion)} - - {/each} -
        - {/if} -
        diff --git a/tools/server/webui/src/lib/components/app/forms/KeyValuePairs.svelte b/tools/server/webui/src/lib/components/app/forms/KeyValuePairs.svelte deleted file mode 100644 index e0bd8d98e..000000000 --- a/tools/server/webui/src/lib/components/app/forms/KeyValuePairs.svelte +++ /dev/null @@ -1,143 +0,0 @@ - - -
        -
        - {#if sectionLabel} - - {sectionLabel} - {#if sectionLabelOptional} - (optional) - {/if} - - {/if} - - -
        - {#if pairs.length > 0} -
        - {#each pairs as pair, index (index)} -
        - updatePairKey(index, e.currentTarget.value)} - onblur={(e) => trimPairKey(index, e.currentTarget.value)} - class="flex-1" - /> - - - - -
        - {/each} -
        - {:else} -

        {emptyMessage}

        - {/if} -
        diff --git a/tools/server/webui/src/lib/components/app/forms/SearchInput.svelte b/tools/server/webui/src/lib/components/app/forms/SearchInput.svelte deleted file mode 100644 index 19dd7e6a7..000000000 --- a/tools/server/webui/src/lib/components/app/forms/SearchInput.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - -
        - - - - - {#if showClearButton} - - {/if} -
        diff --git a/tools/server/webui/src/lib/components/app/forms/index.ts b/tools/server/webui/src/lib/components/app/forms/index.ts deleted file mode 100644 index 4cf56cdc9..000000000 --- a/tools/server/webui/src/lib/components/app/forms/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * - * FORMS & INPUTS - * - * Form-related utility components. - * - */ - -/** - * **InputWithSuggestions** - Input field with autocomplete suggestions - * - * Text input with dropdown suggestions and keyboard navigation. - * Supports autocomplete functionality with suggestion loading. - * - * **Features:** - * - Autocomplete dropdown with suggestions - * - Keyboard navigation (arrow keys, enter) - * - Loading state for suggestions - * - Focus and blur handling - */ -export { default as InputWithSuggestions } from './InputWithSuggestions.svelte'; - -/** - * **KeyValuePairs** - Editable key-value list - * - * Dynamic list of key-value pairs with add/remove functionality. - * Used for HTTP headers, metadata, and configuration. - * - * **Features:** - * - Add new pairs with button - * - Remove individual pairs - * - Customizable placeholders and labels - * - Empty state message - * - Auto-resize value textarea - */ -export { default as KeyValuePairs } from './KeyValuePairs.svelte'; - -/** - * **SearchInput** - Search field with clear button - * - * Input field optimized for search with clear button and keyboard handling. - * Supports placeholder, autofocus, and change callbacks. - */ -export { default as SearchInput } from './SearchInput.svelte'; diff --git a/tools/server/webui/src/lib/components/app/index.ts b/tools/server/webui/src/lib/components/app/index.ts deleted file mode 100644 index 4914c743a..000000000 --- a/tools/server/webui/src/lib/components/app/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export * from './actions'; -export * from './badges'; -export * from './chat'; -export * from './content'; -export * from './dialogs'; -export * from './forms'; -export * from './mcp'; -export * from './misc'; -export * from './settings'; -export * from './models'; -export * from './navigation'; -export * from './server'; diff --git a/tools/server/webui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte b/tools/server/webui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte deleted file mode 100644 index 2f732cfd5..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - -{#if !hasEnabledMcpServers} - -{:else if mcpFavicons.length > 0} - -{/if} diff --git a/tools/server/webui/src/lib/components/app/mcp/McpCapabilitiesBadges.svelte b/tools/server/webui/src/lib/components/app/mcp/McpCapabilitiesBadges.svelte deleted file mode 100644 index d17b24ebb..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpCapabilitiesBadges.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - -{#if capabilities} - {#if capabilities.server.tools} - - - - Tools - - {/if} - - {#if capabilities.server.resources} - - - - Resources - - {/if} - - {#if capabilities.server.prompts} - - - - Prompts - - {/if} - - {#if capabilities.server.logging} - - - - Logging - - {/if} - - {#if capabilities.server.completions} - - - - Completions - - {/if} - - {#if capabilities.server.tasks} - - - - Tasks - - {/if} -{/if} diff --git a/tools/server/webui/src/lib/components/app/mcp/McpConnectionLogs.svelte b/tools/server/webui/src/lib/components/app/mcp/McpConnectionLogs.svelte deleted file mode 100644 index 305c9db3a..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpConnectionLogs.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - -{#if logs.length > 0} - -
        - - {#if isExpanded} - - {:else} - - {/if} - - Connection Log ({logs.length}) - - {#if connectionTimeMs !== undefined} - · Connected in {connectionTimeMs}ms - {/if} - -
        - - -
        - {#each logs as log (log.timestamp.getTime() + log.message)} - {@const IconComponent = getMcpLogLevelIcon(log.level)} - -
        - - {formatTime(log.timestamp)} - - - - - {log.message} -
        - - {#if log.details !== undefined} -
        - details - -
        -{formatLogDetails(log.details)}
        -
        - {/if} - {/each} -
        -
        -
        -{/if} diff --git a/tools/server/webui/src/lib/components/app/mcp/McpLogo.svelte b/tools/server/webui/src/lib/components/app/mcp/McpLogo.svelte deleted file mode 100644 index 9f73db84d..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpLogo.svelte +++ /dev/null @@ -1,111 +0,0 @@ - - - diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourcePreview.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourcePreview.svelte deleted file mode 100644 index 55e1e20a2..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpResourcePreview.svelte +++ /dev/null @@ -1,174 +0,0 @@ - - -
        - {#if !resource} -
        - - - Select a resource to preview -
        - {:else} -
        -
        -

        {resource.title || resource.name}

        - -

        {resource.uri}

        - - {#if resource.description} -

        {resource.description}

        - {/if} -
        - -
        - - - -
        -
        - -
        - {#if isLoading} -
        - -
        - {:else if error} -
        - - - {error} -
        - {:else if content} - {@const textContent = getResourceTextContent(content)} - {@const blobContent = getResourceBlobContent(content)} - - {#if textContent} -
        {textContent}
        - {/if} - - {#each blobContent as blob (blob.uri)} - {#if isImageMimeType(blob.mimeType ?? MimeTypeApplication.OCTET_STREAM)} - Resource content - {:else} -
        - - - Binary content ({blob.mimeType || 'unknown type'}) -
        - {/if} - {/each} - - {#if !textContent && blobContent.length === 0} -
        No content available
        - {/if} - {/if} -
        - - {#if resource.mimeType || resource.annotations} -
        - {#if resource.mimeType} - {resource.mimeType} - {/if} - - {#if resource.annotations?.priority !== undefined} - - Priority: {resource.annotations.priority} - - {/if} - - - Server: {resource.serverName} - -
        - {/if} - {/if} -
        diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourceTemplateForm.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourceTemplateForm.svelte deleted file mode 100644 index f62632514..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpResourceTemplateForm.svelte +++ /dev/null @@ -1,171 +0,0 @@ - - -
        - {#each variables as variable (variable.name)} - handleArgInput(variable.name, value)} - onKeydown={(e) => handleArgKeydown(e, variable.name)} - onBlur={() => handleArgBlur(variable.name)} - onFocus={() => handleArgFocus(variable.name)} - onSelectSuggestion={(value) => selectSuggestion(variable.name, value)} - /> - {/each} - - {#if isComplete} -
        -

        Resolved URI:

        - -

        {expandedUri}

        -
        - {/if} - -
        - - - -
        - diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowser.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowser.svelte deleted file mode 100644 index 24538e8d7..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowser.svelte +++ /dev/null @@ -1,153 +0,0 @@ - - -
        - (searchQuery = q)} - {searchQuery} - /> - -
        - {#if filteredResources.size === 0} - - {:else} - {#each [...filteredResources.entries()] as [serverName, serverRes] (serverName)} - toggleServer(serverName as string)} - onToggleFolder={toggleFolder} - {onSelect} - {onToggle} - {onTemplateSelect} - {searchQuery} - /> - {/each} - {/if} -
        -
        diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserEmptyState.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserEmptyState.svelte deleted file mode 100644 index 4fb0c1e24..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserEmptyState.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -
        - {#if isLoading} - Loading resources... - {:else} - No resources available - {/if} -
        diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserHeader.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserHeader.svelte deleted file mode 100644 index 419654c13..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserHeader.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - -
        -
        - onSearch?.(value)} - /> - - -
        - -

        Available resources

        -
        diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserServerItem.svelte b/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserServerItem.svelte deleted file mode 100644 index 9acd101cd..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserServerItem.svelte +++ /dev/null @@ -1,230 +0,0 @@ - - -{#snippet renderTreeNode(node: ResourceTreeNode, depth: number, parentPath: string)} - {@const isFolder = !node.resource && node.children.size > 0} - {@const folderId = `${serverName}:${parentPath}/${node.name}`} - {@const isFolderExpanded = expandedFolders.has(folderId)} - - {#if isFolder} - {@const folderCount = countTreeResources(node)} - onToggleFolder(folderId)}> - - {#if isFolderExpanded} - - {:else} - - {/if} - - - - {node.name} - - ({folderCount}) - - - -
        - {#each sortTreeChildren( [...node.children.values()] ) as child (child.resource?.uri || `${serverName}:${parentPath}/${node.name}/${child.name}`)} - {@render renderTreeNode(child, depth + 1, `${parentPath}/${node.name}`)} - {/each} -
        -
        -
        - {:else if node.resource} - {@const resource = node.resource} - {@const ResourceIcon = getResourceIcon(resource.mimeType, resource.uri)} - {@const isSelected = isResourceSelected(resource)} - {@const resourceDisplayName = resource.title || getDisplayName(node.name)} - -
        - {#if onToggle} - - handleCheckboxChange(resource, checked === true)} - class="h-4 w-4" - /> - {/if} - - -
        - {/if} -{/snippet} - - - - {#if isExpanded} - - {:else} - - {/if} - - -
        - -
        - - - ({serverRes.resources.length} resource{serverRes.resources.length !== 1 - ? 's' - : ''}{#if hasTemplates}, {serverRes.templates.length} template{serverRes.templates - .length !== 1 - ? 's' - : ''}{/if}) - -
        - - {#if serverRes.loading} - - {/if} -
        - - -
        - {#if serverRes.error} -
        - Error: {serverRes.error} -
        - {:else if !hasContent} -
        No resources
        - {:else} - {#if hasResources} - {#each sortTreeChildren( [...resourceTree.children.values()] ) as child (child.resource?.uri || `${serverName}:${child.name}`)} - {@render renderTreeNode(child, 1, '')} - {/each} - {/if} - - {#if hasTemplates && onTemplateSelect} - {#if hasResources} -
        - {/if} - -
        - Templates -
        - - {#each templateInfos as template (template.uriTemplate)} - - {/each} - {/if} - {/if} -
        -
        -
        diff --git a/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/mcp-resources-browser.ts b/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/mcp-resources-browser.ts deleted file mode 100644 index 804fa7fe2..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpResourcesBrowser/mcp-resources-browser.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { MCPResource, MCPResourceInfo } from '$lib/types'; -import { parseResourcePath } from '$lib/utils'; - -export interface ResourceTreeNode { - name: string; - resource?: MCPResourceInfo; - children: Map; - isFiltered?: boolean; -} - -function resourceMatchesSearch(resource: MCPResource, query: string): boolean { - return ( - resource.title?.toLowerCase().includes(query) || resource.uri.toLowerCase().includes(query) - ); -} - -export function buildResourceTree( - resourceList: MCPResource[], - serverName: string, - searchQuery?: string -): ResourceTreeNode { - const root: ResourceTreeNode = { name: 'root', children: new Map() }; - - if (!searchQuery || !searchQuery.trim()) { - for (const resource of resourceList) { - const pathParts = parseResourcePath(resource.uri); - let current = root; - - for (let i = 0; i < pathParts.length - 1; i++) { - const part = pathParts[i]; - if (!current.children.has(part)) { - current.children.set(part, { name: part, children: new Map() }); - } - current = current.children.get(part)!; - } - - const fileName = pathParts[pathParts.length - 1] || resource.name; - current.children.set(resource.uri, { - name: fileName, - resource: { ...resource, serverName }, - children: new Map() - }); - } - - return root; - } - - const query = searchQuery.toLowerCase(); - - // Build tree with filtering - for (const resource of resourceList) { - if (!resourceMatchesSearch(resource, query)) continue; - - const pathParts = parseResourcePath(resource.uri); - let current = root; - - for (let i = 0; i < pathParts.length - 1; i++) { - const part = pathParts[i]; - if (!current.children.has(part)) { - current.children.set(part, { name: part, children: new Map(), isFiltered: true }); - } - current = current.children.get(part)!; - } - - const fileName = pathParts[pathParts.length - 1] || resource.name; - - current.children.set(resource.uri, { - name: fileName, - resource: { ...resource, serverName }, - children: new Map(), - isFiltered: true - }); - } - - function cleanupEmptyFolders(node: ResourceTreeNode): boolean { - if (node.resource) return true; - - const toDelete: string[] = []; - for (const [name, child] of node.children.entries()) { - if (!cleanupEmptyFolders(child)) { - toDelete.push(name); - } - } - - for (const name of toDelete) { - node.children.delete(name); - } - - return node.children.size > 0; - } - - cleanupEmptyFolders(root); - - return root; -} - -export function countTreeResources(node: ResourceTreeNode): number { - if (node.resource) return 1; - let count = 0; - - for (const child of node.children.values()) { - count += countTreeResources(child); - } - - return count; -} - -export function sortTreeChildren(children: ResourceTreeNode[]): ResourceTreeNode[] { - return children.sort((a, b) => { - const aIsFolder = !a.resource && a.children.size > 0; - const bIsFolder = !b.resource && b.children.size > 0; - - if (aIsFolder && !bIsFolder) return -1; - if (!aIsFolder && bIsFolder) return 1; - - return a.name.localeCompare(b.name); - }); -} diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte deleted file mode 100644 index 199cb1458..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte +++ /dev/null @@ -1,192 +0,0 @@ - - - - {#if isEditing} - - {:else} - - - {#if isError && errorMessage} -

        {errorMessage}

        - {/if} - - {#if isConnected && serverInfo?.description} -

        - {serverInfo.description} -

        - {/if} - -
        - {#if showSkeleton} -
        -
        - - -
        -
        - - - -
        -
        - -
        -
        - - -
        -
        - {:else} - {#if isConnected && instructions} - - {/if} - - {#if tools.length > 0} - - {/if} - - {#if connectionLogs.length > 0} - - {/if} - {/if} -
        - -
        - {#if showSkeleton} - - {:else if protocolVersion} -
        - - Protocol version: {protocolVersion} - -
        - {/if} - - -
        - {/if} -
        - - (showDeleteDialog = open)} - onConfirm={onDelete} -/> diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardActions.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardActions.svelte deleted file mode 100644 index 6f137fa21..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardActions.svelte +++ /dev/null @@ -1,40 +0,0 @@ - - -
        - - - - - -
        diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardDeleteDialog.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardDeleteDialog.svelte deleted file mode 100644 index 8f650148a..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardDeleteDialog.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - Delete Server - - - Are you sure you want to delete {displayName}? This action cannot be - undone. - - - - - Cancel - - - Delete - - - - diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte deleted file mode 100644 index 6727a9000..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte +++ /dev/null @@ -1,64 +0,0 @@ - - -
        -

        Configure Server

        - - (editUrl = v)} - onHeadersChange={(v) => (editHeaders = v)} - onUseProxyChange={(v) => (editUseProxy = v)} - urlError={editUrl ? urlError : null} - id={serverId} - /> - -
        - - - -
        -
        diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardHeader.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardHeader.svelte deleted file mode 100644 index 5544bcec4..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardHeader.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - -
        -
        -
        -
        - -
        - - {#if capabilities || transportType} -
        - {#if transportType} - {@const TransportIcon = MCP_TRANSPORT_ICONS[transportType]} - - {#if TransportIcon} - - {/if} - - {MCP_TRANSPORT_LABELS[transportType] || transportType} - - {/if} - - {#if capabilities} - - {/if} -
        - {/if} -
        - -
        - -
        -
        -
        diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardToolsList.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardToolsList.svelte deleted file mode 100644 index d0397c17a..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpServerCard/McpServerCardToolsList.svelte +++ /dev/null @@ -1,47 +0,0 @@ - - - - - {#if isExpanded} - - {:else} - - {/if} - - {toolsCount} tools available · Show details - - - -
        - {#each tools as tool (tool.name)} -
        - {tool.name} - - {#if tool.description} -

        {tool.description}

        - {/if} -
        - {/each} -
        -
        -
        diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerCardSkeleton.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerCardSkeleton.svelte deleted file mode 100644 index 39a137280..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpServerCardSkeleton.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - - -
        -
        - - - -
        - -
        - -
        - - - -
        - -
        - - -
        - - - -
        - - - -
        -
        diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte deleted file mode 100644 index 22eca1231..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - -
        -
        - - - onUrlChange(e.currentTarget.value)} - class={urlError ? 'border-destructive' : ''} - /> - - {#if urlError} -

        {urlError}

        - {/if} - - {#if !isWebSocket && onUseProxyChange} - - {/if} -
        - - -
        diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerIdentity.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerIdentity.svelte deleted file mode 100644 index feafc5d81..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpServerIdentity.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - - - {#if faviconUrl} - { - (e.currentTarget as HTMLImageElement).style.display = 'none'; - }} - /> - {/if} - - - - {#if showVersion && serverInfo?.version} - - - - {/if} - - {#if showWebsite && safeWebsiteUrl} - e.stopPropagation()} - > - - - {/if} - diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerInfo.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerInfo.svelte deleted file mode 100644 index aecae6e57..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/McpServerInfo.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -{#if instructions} - - - {#if isExpanded} - - {:else} - - {/if} - - Server instructions - - - -

        - {instructions} -

        -
        -
        -{/if} diff --git a/tools/server/webui/src/lib/components/app/mcp/index.ts b/tools/server/webui/src/lib/components/app/mcp/index.ts deleted file mode 100644 index 3d30bb3b4..000000000 --- a/tools/server/webui/src/lib/components/app/mcp/index.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * - * MCP (Model Context Protocol) - * - * Components for managing MCP server connections and displaying server status. - * MCP enables agentic workflows by connecting to external tool servers. - * - * The MCP system integrates with: - * - `mcpStore` for server CRUD operations and health checks - * - `conversationsStore` for per-conversation server enable/disable - * - */ - -/** - * **McpServersSettings** - MCP servers configuration section - * - * Settings section for configuring MCP server connections. - * Displays server cards with status, tools, and management actions. - * Used within the MCP tab of ChatSettings. - * - * **Architecture:** - * - Manages add server form state locally - * - Delegates server display to McpServerCard components - * - Integrates with mcpStore for server operations - * - Shows skeleton loading states during health checks - * - * **Features:** - * - Add new MCP servers by URL with validation - * - Server cards with connection status indicators - * - Health check status (connected/disconnected/error) - * - Tools list per server showing available capabilities - * - Enable/disable toggle per conversation - * - Edit/delete server actions - * - Skeleton loading states during connection - * - Empty state with helpful message - * - * @example - * ```svelte - * - * ``` - */ -export { default as McpServersSettings } from '../settings/SettingsMcpServers.svelte'; - -/** - * **McpActiveServersAvatars** - Active MCP servers indicator - * - * Compact avatar row showing favicons of active MCP servers. - * Displays up to 3 server icons with "+N" counter for additional servers. - * Clickable to open MCP settings dialog. - * - * **Architecture:** - * - Filters servers by enabled status and health check - * - Fetches favicons from server URLs - * - Integrates with conversationsStore for per-chat server state - * - * **Features:** - * - Overlapping favicon avatars (max 3 visible) - * - "+N" counter for additional servers - * - Click handler for settings navigation - * - Disabled state support - * - Only shows healthy, enabled servers - * - * @example - * ```svelte - * showMcpSettings = true} - * /> - * ``` - */ -export { default as McpActiveServersAvatars } from './McpActiveServersAvatars.svelte'; - -/** - * **McpCapabilitiesBadges** - Server capabilities display - * - * Displays MCP server capabilities as colored badges. - * Shows which features the server supports (tools, resources, prompts, etc.). - * - * **Features:** - * - Tools badge (green) - server provides callable tools - * - Resources badge (blue) - server provides data resources - * - Prompts badge (purple) - server provides prompt templates - * - Logging badge (orange) - server supports logging - * - Completions badge (cyan) - server provides completions - * - Tasks badge (pink) - server supports task management - */ -export { default as McpCapabilitiesBadges } from './McpCapabilitiesBadges.svelte'; - -/** - * **McpConnectionLogs** - Connection log viewer - * - * Collapsible panel showing MCP server connection logs. - * Displays timestamped log entries with level-based styling. - * - * **Features:** - * - Collapsible log list with entry count - * - Connection time display in milliseconds - * - Log level icons and color coding - * - Scrollable log container with max height - * - Monospace font for log readability - */ -export { default as McpConnectionLogs } from './McpConnectionLogs.svelte'; - -/** - * **McpServerForm** - Server URL and headers input form - * - * Reusable form for entering MCP server connection details. - * Used in both add new server and edit server flows. - * - * **Features:** - * - URL input with validation error display - * - Custom headers key-value pairs editor - * - Controlled component with change callbacks - * - * @example - * ```svelte - * serverUrl = v} - * onHeadersChange={(v) => serverHeaders = v} - * urlError={validationError} - * /> - * ``` - */ -export { default as McpServerForm } from './McpServerForm.svelte'; - -/** - * MCP protocol logo SVG component. Renders the official MCP icon - * with customizable size via class and style props. - */ -export { default as McpLogo } from './McpLogo.svelte'; - -/** - * - * SERVER CARD - * - * Components for displaying individual MCP server status and controls. - * McpServerCard is the main component, with sub-components for specific sections. - * - */ - -/** - * **McpServerCard** - Individual server display card - * - * Main component for displaying a single MCP server with all its details. - * Manages edit mode, delete confirmation, and health check actions. - * - * **Architecture:** - * - Composes header, tools list, logs, and actions sub-components - * - Manages local edit/delete state - * - Reads health state from mcpStore - * - Triggers health checks via mcpStore - * - * **Features:** - * - Server header with favicon, name, version, and toggle - * - Capabilities badges display - * - Tools list with descriptions - * - Connection logs viewer - * - Edit form for URL and headers - * - Delete confirmation dialog - * - Skeleton loading states - */ -export { default as McpServerCard } from './McpServerCard/McpServerCard.svelte'; - -/** Server card header with favicon, name, version badge, and enable toggle. */ -export { default as McpServerCardHeader } from './McpServerCard/McpServerCardHeader.svelte'; - -/** Action buttons row: edit, refresh, delete. */ -export { default as McpServerCardActions } from './McpServerCard/McpServerCardActions.svelte'; - -/** Collapsible tools list showing available server tools with descriptions. */ -export { default as McpServerCardToolsList } from './McpServerCard/McpServerCardToolsList.svelte'; - -/** Inline edit form for server URL and custom headers. */ -export { default as McpServerCardEditForm } from './McpServerCard/McpServerCardEditForm.svelte'; - -/** Delete confirmation dialog with server name display. */ -export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerCardDeleteDialog.svelte'; - -/** Skeleton loading state for server card during health checks. */ -export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte'; - -/** - * **McpServerIdentity** - Server identity display (icon, name, version) - * - * Reusable headless component for displaying server name, favicon/icon, and version badge. - * Accepts all data via props with no store dependencies for predictable rendering. - * - * **Features:** - * - Server favicon/icon with fallback - * - Truncated display name with max-width - * - Optional version badge (v1.2.3) - * - Optional external link to server website - * - * @example - * ```svelte - * - * ``` - */ -export { default as McpServerIdentity } from './McpServerIdentity.svelte'; - -/** - * **McpServerInfo** - Server instructions display - * - * Collapsible panel showing server-provided instructions. - * Displays guidance text from the MCP server for users. - */ -export { default as McpServerInfo } from './McpServerInfo.svelte'; - -/** - * **McpResourcesBrowser** - MCP resources tree browser - * - * Tree view component showing resources grouped by server. - * Supports resource selection and quick attach actions. - * - * **Features:** - * - Collapsible server sections - * - Resource icons based on MIME type - * - Resource selection highlighting - * - Quick attach button per resource - * - Refresh all resources action - * - Loading states per server - */ -export { default as McpResourcesBrowser } from './McpResourcesBrowser/McpResourcesBrowser.svelte'; - -/** - * **McpResourcePreview** - MCP resource content preview - * - * Preview panel showing resource content with metadata. - * Supports text and binary content display. - * - * **Features:** - * - Text content display with monospace formatting - * - Image preview for image MIME types - * - Copy to clipboard action - * - Download content action - * - Resource metadata display (MIME type, priority, server) - * - Loading and error states - */ -export { default as McpResourcePreview } from './McpResourcePreview.svelte'; - -/** - * **McpResourceTemplateForm** - MCP resource template variable form - * - * Form for filling in resource template variables with auto-completion - * via the Completions API. Shows live URI preview as variables are filled. - * - * **Features:** - * - Template variable input fields - * - Completions API integration for variable auto-complete - * - Live URI preview as variables are filled - * - Read resolved resource action - */ -export { default as McpResourceTemplateForm } from './McpResourceTemplateForm.svelte'; diff --git a/tools/server/webui/src/lib/components/app/misc/CodeBlockActions.svelte b/tools/server/webui/src/lib/components/app/misc/CodeBlockActions.svelte deleted file mode 100644 index fa12d1c62..000000000 --- a/tools/server/webui/src/lib/components/app/misc/CodeBlockActions.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - -
        - - - {#if showPreview} - onPreview!(code, language)} - /> - {/if} -
        diff --git a/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte b/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte deleted file mode 100644 index db14fd631..000000000 --- a/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte +++ /dev/null @@ -1,194 +0,0 @@ - - -
        - - -
        - - {selectedIds.size} of {conversations.length} selected - {#if searchQuery} - ({filteredConversations.length} shown) - {/if} - -
        - -
        - - - - - - - - - - - - - {#if filteredConversations.length === 0} - - - - {:else} - {#each filteredConversations as conv (conv.id)} - toggleConversation(conv.id, event.shiftKey)} - > - - - - - - - {/each} - {/if} - -
        - - Conversation NameMessages
        - {#if searchQuery} - No conversations found matching "{searchQuery}" - {:else} - No conversations available - {/if} -
        - { - event.preventDefault(); - event.stopPropagation(); - toggleConversation(conv.id, event.shiftKey); - }} - /> - -
        - {conv.name || 'Untitled conversation'} -
        -
        - {messageCountMap.get(conv.id) ?? 0} -
        -
        -
        - -
        - - - -
        -
        diff --git a/tools/server/webui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte b/tools/server/webui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte deleted file mode 100644 index 06d0e3a05..000000000 --- a/tools/server/webui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - -
        - - -
        - {@render children?.()} -
        - - -
        diff --git a/tools/server/webui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte b/tools/server/webui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte deleted file mode 100644 index da55abda0..000000000 --- a/tools/server/webui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - {#each keys as key, index (index)} - {#if key === 'shift'} - - {:else if key === 'cmd'} - ⌘ - {:else} - {key.toUpperCase()} - {/if} - - {#if index < keys.length - 1} - - {/if} - {/each} - diff --git a/tools/server/webui/src/lib/components/app/misc/TruncatedText.svelte b/tools/server/webui/src/lib/components/app/misc/TruncatedText.svelte deleted file mode 100644 index a6b7cb483..000000000 --- a/tools/server/webui/src/lib/components/app/misc/TruncatedText.svelte +++ /dev/null @@ -1,49 +0,0 @@ - - -{#if isTruncated && showTooltip} - - - - {text} - - - - -

        {text}

        -
        -
        -{:else} - - {text} - -{/if} diff --git a/tools/server/webui/src/lib/components/app/misc/index.ts b/tools/server/webui/src/lib/components/app/misc/index.ts deleted file mode 100644 index 64b76fb71..000000000 --- a/tools/server/webui/src/lib/components/app/misc/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * - * MISC - * - * Miscellaneous utility components. - * - */ - -/** - * **ConversationSelection** - Multi-select conversation picker - * - * List of conversations with checkboxes for multi-selection. - * Used in import/export dialogs for selecting conversations. - * - * **Features:** - * - Search/filter conversations by name - * - Select all / deselect all controls - * - Shift-click for range selection - * - Message count display per conversation - * - Mode-specific UI (export vs import) - */ -export { default as ConversationSelection } from './ConversationSelection.svelte'; - -/** - * Horizontal scrollable carousel with navigation arrows. - * Used for displaying items in a horizontally scrollable container - * with left/right navigation buttons that appear on hover. - */ -export { default as HorizontalScrollCarousel } from './HorizontalScrollCarousel.svelte'; - -/** - * **TruncatedText** - Text with ellipsis and tooltip - * - * Displays text with automatic truncation and full content in tooltip. - * Useful for long names or paths in constrained spaces. - */ -export { default as TruncatedText } from './TruncatedText.svelte'; - -/** - * **KeyboardShortcutInfo** - Keyboard shortcut hint display - * - * Displays keyboard shortcut hints (e.g., "⌘ + Enter"). - * Supports special keys like shift, cmd, and custom text. - */ -export { default as KeyboardShortcutInfo } from './KeyboardShortcutInfo.svelte'; - -/** - * **CodeBlockActions** - Actions bar for code blocks (copy, preview) - * - * Displays copy-to-clipboard and preview buttons for code blocks. - * Preview button is shown only for HTML code blocks. - */ -export { default as CodeBlockActions } from './CodeBlockActions.svelte'; diff --git a/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte b/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte deleted file mode 100644 index cc1d1848e..000000000 --- a/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - -{#snippet badgeContent()} - - {#snippet icon()} - - {/snippet} - - {#if model} - - {/if} - - {#if showCopyIcon} - - {/if} - -{/snippet} - -{#if shouldShow} - {#if showTooltip} - - - {@render badgeContent()} - - - - {onclick ? 'Click for model details' : model} - - - {:else} - {@render badgeContent()} - {/if} -{/if} diff --git a/tools/server/webui/src/lib/components/app/models/ModelId.svelte b/tools/server/webui/src/lib/components/app/models/ModelId.svelte deleted file mode 100644 index 2fe952fee..000000000 --- a/tools/server/webui/src/lib/components/app/models/ModelId.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - -{#if resolvedShowRaw} - -{:else} - - - {#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName} - - - {#if parsed.params} - - {parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''} - - {/if} - - {#if parsed.quantization && !hideQuantization} - - {parsed.quantization} - - {/if} - - {#if primaryAlias} - {#if primaryAlias !== parsed.modelName} - {parsed.modelName ?? modelId} - {/if} - {:else if uniqueAliases.length > 1} - {#each uniqueAliases as alias (alias)} - {alias} - {/each} - {/if} - - {#if uniqueTags.length > 0} - {#each uniqueTags as tag (tag)} - {tag} - {/each} - {/if} - -{/if} diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelectorDropdown.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelectorDropdown.svelte deleted file mode 100644 index 998a6a0fa..000000000 --- a/tools/server/webui/src/lib/components/app/models/ModelsSelectorDropdown.svelte +++ /dev/null @@ -1,290 +0,0 @@ - - -
        - {#if ms.loading && ms.options.length === 0 && ms.isRouter} -
        - - - Loading models… -
        - {:else if ms.options.length === 0 && ms.isRouter} - {#if currentModel} - - - - - - {:else} -

        No models available.

        - {/if} - {:else} - {@const selectedOption = ms.getDisplayOption()} - - {#if ms.isRouter} - - - - - {#if selectedOption} - - - - {#snippet child({ props })} - - {/snippet} - - - -

        {selectedOption.model}

        -
        -
        - {:else} - Select model - {/if} - - {#if ms.updating || ms.isLoadingModel} - - {:else} - - {/if} -
        - - - ms.setSearchTerm(v)} - placeholder="Search models..." - onSearchKeyDown={handleSearchKeyDown} - emptyMessage="No models found." - isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache} - > -
        - {#if !ms.isCurrentModelInCache && currentModel} - - - {/if} - - {#if ms.filteredOptions.length === 0} -

        No models found.

        - {/if} - - {#snippet modelOption(item: ModelItem, hideOrgName: boolean)} - {@const { option, flatIndex } = item} - {@const isSelected = currentModel === option.model || ms.activeId === option.id} - {@const isHighlighted = flatIndex === highlightedIndex} - {@const isFav = ms.isFavorite(option.model)} - - (highlightedIndex = flatIndex)} - onKeyDown={(event) => { - if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) { - event.preventDefault(); - ms.handleSelect(option.id); - } - }} - /> - {/snippet} - - -
        -
        -
        -
        - {:else} - - {/if} - {/if} -
        - -{#if ms.showModelDialog} - ms.setShowModelDialog(v)} - modelId={ms.infoModelId} - /> -{/if} diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelectorList.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelectorList.svelte deleted file mode 100644 index 61a4cf0f6..000000000 --- a/tools/server/webui/src/lib/components/app/models/ModelsSelectorList.svelte +++ /dev/null @@ -1,72 +0,0 @@ - - -{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)} - {@const { option } = item} - {@const isSelected = currentModel === option.model || activeId === option.id} - {@const isFav = modelsStore.favoriteModelIds.has(option.model)} - - {}} - onKeyDown={() => {}} - /> -{/snippet} - -{#if groups.loaded.length > 0} -

        Loaded models

        - {#each groups.loaded as item (`loaded-${item.option.id}`)} - {@render render(item, false)} - {/each} -{/if} - -{#if groups.favorites.length > 0} -

        Favorite models

        - {#each groups.favorites as item (`fav-${item.option.id}`)} - {@render render(item, true)} - {/each} -{/if} - -{#if groups.available.length > 0} -

        Available models

        - {#each groups.available as group (group.orgName)} - {#if group.orgName} -

        {group.orgName}

        - {/if} - {#each group.items as item (item.option.id)} - {@render render(item, true)} - {/each} - {/each} -{/if} diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelectorOption.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelectorOption.svelte deleted file mode 100644 index d103d4b67..000000000 --- a/tools/server/webui/src/lib/components/app/models/ModelsSelectorOption.svelte +++ /dev/null @@ -1,181 +0,0 @@ - - -
        onSelect(option.id)} - onmouseenter={onMouseEnter} - onkeydown={onKeyDown} -> - - -
        - - -
        e.stopPropagation()} - > - {#if isFav} - modelsStore.toggleFavorite(option.model)} - /> - {:else} - modelsStore.toggleFavorite(option.model)} - /> - {/if} - - - {#if isLoaded && onInfoClick} - onInfoClick(option.model)} - /> - {/if} -
        - - {#if isLoading} - - {:else if isFailed} -
        - - - -
        - {:else if isSleeping} -
        - - - -
        - {:else if isLoaded} -
        - - - -
        - {:else} -
        - - - -
        - {/if} -
        -
        diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelectorSheet.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelectorSheet.svelte deleted file mode 100644 index d38ed8c07..000000000 --- a/tools/server/webui/src/lib/components/app/models/ModelsSelectorSheet.svelte +++ /dev/null @@ -1,189 +0,0 @@ - - -
        - {#if ms.loading && ms.options.length === 0 && ms.isRouter} -
        - - Loading models… -
        - {:else if ms.options.length === 0 && ms.isRouter} -

        No models available.

        - {:else} - {@const selectedOption = ms.getDisplayOption()} - - {#if ms.isRouter} - - - - - - Select Model - - - Choose a model to use for the conversation - - - -
        -
        - ms.setSearchTerm(v)} - /> -
        - -
        - {#if !ms.isCurrentModelInCache && currentModel} - -
        - {/if} - - {#if ms.filteredOptions.length === 0} -

        No models found.

        - {/if} - - -
        -
        -
        -
        - {:else} - - {/if} - {/if} -
        - -{#if ms.showModelDialog} - ms.setShowModelDialog(v)} - modelId={ms.infoModelId} - /> -{/if} diff --git a/tools/server/webui/src/lib/components/app/models/index.ts b/tools/server/webui/src/lib/components/app/models/index.ts deleted file mode 100644 index a6ba6817f..000000000 --- a/tools/server/webui/src/lib/components/app/models/index.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * - * MODELS - * - * Components for model selection and display. Supports two server modes: - * - **Single model mode**: Server runs with one model, selector shows model info - * - **Router mode**: Server runs with multiple models, selector enables switching - * - * Integrates with modelsStore for model data and serverStore for mode detection. - * - */ - -/** - * **ModelsSelectorDropdown** - Model selection dropdown (desktop) - * - * Dropdown for selecting AI models with status indicators, - * search, and model information display. Adapts UI based on server mode. - * - * **Architecture:** - * - Uses DropdownMenuSearchable for model list - * - Integrates with modelsStore for model options and selection - * - Detects router vs single mode from serverStore - * - Opens DialogModelInformation for model details - * - * **Features:** - * - Searchable model list with keyboard navigation - * - Model status indicators (loading/ready/error/updating) - * - Model capabilities badges (vision, tools, etc.) - * - Current/active model highlighting - * - Model information dialog on info button click - * - Router mode: shows all available models with status - * - Single mode: shows current model name only - * - Loading/updating skeleton states - * - Global selection support for form integration - * - * @example - * ```svelte - * updateModel(id)} - * useGlobalSelection - * /> - * ``` - */ -export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte'; - -/** - * **ModelsSelectorList** - Grouped model options list - * - * Renders grouped model options (loaded, favorites, available) with section - * headers and org subgroups. Shared between ModelsSelectorDropdown and ModelsSelectorSheet - * to avoid template duplication. - * - * Accepts an optional `renderOption` snippet to customize how each option is - * rendered (e.g., to add keyboard navigation or highlighting). - */ -export { default as ModelsSelectorList } from './ModelsSelectorList.svelte'; - -/** - * **ModelsSelectorOption** - Single model option row - * - * Renders a single model option with selection state, favorite toggle, - * load/unload actions, status indicators, and an info button. - * Used inside ModelsSelectorList or directly in custom render snippets. - */ -export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte'; - -/** - * **ModelsSelectorSheet** - Mobile model selection sheet - * - * Bottom sheet variant of ModelsSelectorDropdown optimized for touch interaction - * on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI - * instead of DropdownMenu. - */ -export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte'; - -/** - * **ModelBadge** - Model name display badge - * - * Compact badge showing current model name with package icon. - * Only visible in single model mode. Supports tooltip and copy functionality. - * - * **Architecture:** - * - Reads model name from modelsStore or prop - * - Checks server mode from serverStore - * - Uses BadgeInfo for consistent styling - * - * **Features:** - * - Optional copy to clipboard button - * - Optional tooltip with model details - * - Click handler for model info dialog - * - Only renders in model mode (not router) - * - * @example - * ```svelte - * showModelInfo = true} - * showTooltip - * showCopyIcon - * /> - * ``` - */ -export { default as ModelBadge } from './ModelBadge.svelte'; - -/** - * **ModelId** - Parsed model identifier display - * - * Displays a model ID with optional org name, parameter badges, quantization, - * aliases, and tags. Supports raw mode to show the unprocessed model name. - * Respects the user's `showRawModelNames` setting. - */ -export { default as ModelId } from './ModelId.svelte'; diff --git a/tools/server/webui/src/lib/components/app/models/utils.ts b/tools/server/webui/src/lib/components/app/models/utils.ts deleted file mode 100644 index ae1f511e9..000000000 --- a/tools/server/webui/src/lib/components/app/models/utils.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { SvelteMap } from 'svelte/reactivity'; -import type { ModelOption } from '$lib/types/models'; - -export interface ModelItem { - option: ModelOption; - flatIndex: number; -} - -export interface OrgGroup { - orgName: string | null; - items: ModelItem[]; -} - -export interface GroupedModelOptions { - loaded: ModelItem[]; - favorites: ModelItem[]; - available: OrgGroup[]; -} - -export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] { - const term = searchTerm.trim().toLowerCase(); - if (!term) return options; - - return options.filter( - (option) => - option.model.toLowerCase().includes(term) || - option.name?.toLowerCase().includes(term) || - option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) || - option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) - ); -} - -export function groupModelOptions( - filteredOptions: ModelOption[], - favoriteIds: Set, - isModelLoaded: (model: string) => boolean -): GroupedModelOptions { - // Loaded models - const loaded: ModelItem[] = []; - for (let i = 0; i < filteredOptions.length; i++) { - if (isModelLoaded(filteredOptions[i].model)) { - loaded.push({ option: filteredOptions[i], flatIndex: i }); - } - } - - // Favorites (excluding loaded) - const loadedModelIds = new Set(loaded.map((item) => item.option.model)); - const favorites: ModelItem[] = []; - for (let i = 0; i < filteredOptions.length; i++) { - if ( - favoriteIds.has(filteredOptions[i].model) && - !loadedModelIds.has(filteredOptions[i].model) - ) { - favorites.push({ option: filteredOptions[i], flatIndex: i }); - } - } - - // Available models grouped by org (excluding loaded and favorites) - const available: OrgGroup[] = []; - const orgGroups = new SvelteMap(); - for (let i = 0; i < filteredOptions.length; i++) { - const option = filteredOptions[i]; - if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue; - - const key = option.parsedId?.orgName ?? ''; - if (!orgGroups.has(key)) orgGroups.set(key, []); - orgGroups.get(key)!.push({ option, flatIndex: i }); - } - - for (const [orgName, items] of orgGroups) { - available.push({ orgName: orgName || null, items }); - } - - return { loaded, favorites, available }; -} diff --git a/tools/server/webui/src/lib/components/app/navigation/DesktopIconStrip.svelte b/tools/server/webui/src/lib/components/app/navigation/DesktopIconStrip.svelte deleted file mode 100644 index e92b9528a..000000000 --- a/tools/server/webui/src/lib/components/app/navigation/DesktopIconStrip.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - diff --git a/tools/server/webui/src/lib/components/app/navigation/DropdownMenuActions.svelte b/tools/server/webui/src/lib/components/app/navigation/DropdownMenuActions.svelte deleted file mode 100644 index 83d856d10..000000000 --- a/tools/server/webui/src/lib/components/app/navigation/DropdownMenuActions.svelte +++ /dev/null @@ -1,86 +0,0 @@ - - - - e.stopPropagation()} - > - {#if triggerTooltip} - - - {@render iconComponent(triggerIcon, 'h-3 w-3')} - {triggerTooltip} - - -

        {triggerTooltip}

        -
        -
        - {:else} - {@render iconComponent(triggerIcon, 'h-3 w-3')} - {/if} -
        - - - {#each actions as action, index (action.label)} - {#if action.separator && index > 0} - - {/if} - - -
        - {@render iconComponent( - action.icon, - `h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}` - )} - {action.label} -
        - - {#if action.shortcut} - - {/if} -
        - {/each} -
        -
        - -{#snippet iconComponent(IconComponent: Component, className: string)} - -{/snippet} diff --git a/tools/server/webui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte b/tools/server/webui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte deleted file mode 100644 index 3bd68d3bd..000000000 --- a/tools/server/webui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - -
        - -
        - -
        - {@render children()} - - {#if isEmpty} -
        {emptyMessage}
        - {/if} -
        - -{#if footer} - - - {@render footer()} -{/if} diff --git a/tools/server/webui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte b/tools/server/webui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte deleted file mode 100644 index 105576bb4..000000000 --- a/tools/server/webui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte +++ /dev/null @@ -1,297 +0,0 @@ - - -
        - - -
        - -

        {APP_NAME}

        -
        - - -
        - - -
        - - - {#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive} - - {isSearchModeActive ? 'Search results' : 'Recent conversations'} - - {/if} - - - - {#each conversationTree as { conversation, depth } (conversation.id)} - - - - {/each} - - {#if conversationTree.length === 0} -
        -

        - {searchQuery.length > 0 - ? 'No results found' - : isSearchModeActive - ? 'Start typing to see results' - : 'No conversations yet'} -

        -
        - {/if} -
        -
        -
        -
        -
        - - { - showDeleteDialog = false; - selectedConversation = null; - }} -> - {#if selectedConversationHasDescendants} -
        - - - -
        - {/if} -
        - - { - showEditDialog = false; - selectedConversation = null; - }} - onKeydown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - event.stopImmediatePropagation(); - handleConfirmEdit(); - } - }} -> - - diff --git a/tools/server/webui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte b/tools/server/webui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte deleted file mode 100644 index f0d63970e..000000000 --- a/tools/server/webui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - -{#snippet itemIcon(IconComponent: Component)} - -{/snippet} - -
        - {#if isSearchModeActive} - e.key === 'Escape' && handleSearchModeDeactivate()} - placeholder="Search conversations..." - {isCancelAlwaysVisible} - /> - {:else} - {#each SIDEBAR_ACTIONS_ITEMS as item (item.route)} - {#if !item.route} - - {:else} - - {/if} - {/each} - {/if} -
        diff --git a/tools/server/webui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte b/tools/server/webui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte deleted file mode 100644 index dad8d954c..000000000 --- a/tools/server/webui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - diff --git a/tools/server/webui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSearch.svelte b/tools/server/webui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSearch.svelte deleted file mode 100644 index afc984702..000000000 --- a/tools/server/webui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSearch.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/tools/server/webui/src/lib/components/app/navigation/index.ts b/tools/server/webui/src/lib/components/app/navigation/index.ts deleted file mode 100644 index d4ca91459..000000000 --- a/tools/server/webui/src/lib/components/app/navigation/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * - * NAVIGATION & MENUS - * - * Components for dropdown menus and action selection. - * - */ - -/** - * **DropdownMenuSearchable** - Searchable content for dropdown menus - * - * Renders a search input with filtered content area, empty state, and optional footer. - * Designed to be injected into any dropdown container (DropdownMenu.Content, - * DropdownMenu.SubContent, etc.) without providing its own Root. - * - * **Features:** - * - Search/filter input - * - Keyboard navigation support - * - Custom content and footer via snippets - * - Empty state message - * - * @example - * ```svelte - * - * ... - * - * - * {#each items as item}{/each} - * - * - * - * ``` - */ -export { default as DropdownMenuSearchable } from './DropdownMenuSearchable.svelte'; - -/** - * **DropdownMenuActions** - Multi-action dropdown menu - * - * Dropdown menu for multiple action options with icons and shortcuts. - * Supports destructive variants and keyboard shortcut hints. - * - * **Features:** - * - Configurable trigger icon with tooltip - * - Action items with icons and labels - * - Destructive variant styling - * - Keyboard shortcut display - * - Separator support between groups - * - * @example - * ```svelte - * - * ``` - */ -export { default as DropdownMenuActions } from './DropdownMenuActions.svelte'; - -/** - * **DesktopIconStrip** - Fixed icon strip for desktop sidebar - * - * Vertical icon strip shown on desktop when the sidebar is collapsed. - * Contains navigation shortcuts for new chat, search, MCP, import/export, and settings. - */ -export { default as DesktopIconStrip } from './DesktopIconStrip.svelte'; - -/** - * **SidebarNavigation** - Sidebar with actions menu and conversation list - * - * Collapsible sidebar displaying conversation history with search and - * management actions. Integrates with ShadCN sidebar component for - * consistent styling and mobile responsiveness. - * - * **Architecture:** - * - Uses ShadCN Sidebar.* components for structure - * - Fetches conversations from conversationsStore - * - Manages search state and filtered results locally - * - Handles conversation CRUD operations via conversationsStore - * - * **Navigation:** - * - Click conversation to navigate to `/chat/[id]` - * - New chat button navigates to `/` (root) - * - Active conversation highlighted based on route params - * - * **Conversation Management:** - * - Right-click or menu button for context menu - * - Rename: Opens inline edit dialog - * - Delete: Shows confirmation with conversation preview - * - Delete All: Removes all conversations with confirmation - * - * **Features:** - * - Search/filter conversations by title - * - Conversation list with message previews (first message truncated) - * - Active conversation highlighting - * - Mobile-responsive collapse/expand via ShadCN sidebar - * - New chat button in header - * - Settings button opens DialogChatSettings - * - * **Exported API:** - * - `activateSearchMode()` - Focus search input programmatically - * - `editActiveConversation()` - Open rename dialog for current conversation - * - * @example - * ```svelte - * - * ``` - */ -export { default as SidebarNavigation } from './SidebarNavigation/SidebarNavigation.svelte'; - -/** - * Action buttons for sidebar header. Contains new chat button, settings button, - * and delete all conversations button. Manages dialog states for settings and - * delete confirmation. - */ -export { default as SidebarNavigationActions } from './SidebarNavigation/SidebarNavigationActions.svelte'; - -/** - * Single conversation item in sidebar. Displays conversation title (truncated), - * last message preview, and timestamp. Shows context menu on right-click with - * rename and delete options. Highlights when active (matches current route). - * Handles click to navigate and keyboard accessibility. - */ -export { default as SidebarNavigationConversationItem } from './SidebarNavigation/SidebarNavigationConversationItem.svelte'; - -/** - * Search input for filtering conversations in sidebar. Filters conversation - * list by title as user types. Shows clear button when query is not empty. - * Integrated into sidebar header with proper styling. - */ -export { default as SidebarNavigationSearch } from './SidebarNavigation/SidebarNavigationSearch.svelte'; diff --git a/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte b/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte deleted file mode 100644 index 4da0d1ddf..000000000 --- a/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte +++ /dev/null @@ -1,285 +0,0 @@ - - -
        -
        -
        -
        - -
        - -

        Server Connection Error

        - -

        - {error} -

        -
        - - {#if isAccessDeniedError && !showApiKeyInput} -
        - -
        - {/if} - - {#if showApiKeyInput} -
        -
        - - -
        - - {#if apiKeyState === 'validating'} -
        - -
        - {:else if apiKeyState === 'success'} -
        - -
        - {:else if apiKeyState === 'error'} -
        - -
        - {/if} -
        - {#if apiKeyError} -

        - {apiKeyError} -

        - {/if} - {#if apiKeyState === 'success'} -

        - ✓ API key validated successfully! Connecting... -

        - {/if} -
        -
        - - -
        -
        - {/if} - - {#if showRetry} -
        - -
        - {/if} - - {#if showTroubleshooting} -
        -
        - - Troubleshooting - - -
        -
        -

        Start the llama-server:

        - -
        -

        llama-server -hf ggml-org/gemma-3-4b-it-GGUF

        -
        - -

        or

        - -
        -

        llama-server -m locally-stored-model.gguf

        -
        -
        -
          -
        • Check that the server is accessible at the correct URL
        • - -
        • Verify your network connection
        • - -
        • Check server logs for any error messages
        • -
        -
        -
        -
        - {/if} -
        -
        diff --git a/tools/server/webui/src/lib/components/app/server/ServerLoadingSplash.svelte b/tools/server/webui/src/lib/components/app/server/ServerLoadingSplash.svelte deleted file mode 100644 index 95fa61e93..000000000 --- a/tools/server/webui/src/lib/components/app/server/ServerLoadingSplash.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - -
        -
        -
        -
        - -
        - -

        Connecting to Server

        - -

        - {message} -

        -
        - -
        - -
        -
        -
        diff --git a/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte b/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte deleted file mode 100644 index 86a962de1..000000000 --- a/tools/server/webui/src/lib/components/app/server/ServerStatus.svelte +++ /dev/null @@ -1,65 +0,0 @@ - - -
        -
        -
        - - {getStatusText()} -
        - - {#if serverData && !error} - - - - {model || 'Unknown Model'} - - - {#if serverData?.default_generation_settings?.n_ctx} - - ctx: {serverData.default_generation_settings.n_ctx.toLocaleString()} - - {/if} - {/if} - - {#if showActions && error} - - {/if} -
        diff --git a/tools/server/webui/src/lib/components/app/server/index.ts b/tools/server/webui/src/lib/components/app/server/index.ts deleted file mode 100644 index 39ac5b482..000000000 --- a/tools/server/webui/src/lib/components/app/server/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * - * SERVER - * - * Components for displaying server connection state and handling - * connection errors. Integrates with serverStore for state management. - * - */ - -/** - * **ServerStatus** - Server connection status indicator - * - * Compact status display showing connection state, model name, - * and context size. Used in headers and loading screens. - * - * **Architecture:** - * - Reads state from serverStore (props, loading, error) - * - Displays model name from modelsStore - * - * **Features:** - * - Status dot: green (connected), yellow (connecting), red (error), gray (unknown) - * - Status text label - * - Model name badge with icon - * - Context size badge - * - Optional error action button - * - * @example - * ```svelte - * - * ``` - */ -export { default as ServerStatus } from './ServerStatus.svelte'; - -/** - * **ServerErrorSplash** - Full-screen connection error display - * - * Blocking error screen shown when server connection fails. - * Provides retry options and API key input for authentication errors. - * - * **Architecture:** - * - Detects access denied errors for API key flow - * - Validates API key against server before saving - * - Integrates with settingsStore for API key persistence - * - * **Features:** - * - Error message display with icon - * - Retry connection button with loading state - * - API key input for authentication errors - * - API key validation with success/error feedback - * - Troubleshooting section with server start commands - * - Animated transitions for UI elements - * - * @example - * ```svelte - * - * ``` - */ -export { default as ServerErrorSplash } from './ServerErrorSplash.svelte'; - -/** - * **ServerLoadingSplash** - Full-screen loading display - * - * Shown during initial server connection. Displays loading animation - * with ServerStatus component for real-time connection state. - * - * **Features:** - * - Animated server icon - * - Customizable loading message - * - Embedded ServerStatus for live updates - * - * @example - * ```svelte - * - * ``` - */ -export { default as ServerLoadingSplash } from './ServerLoadingSplash.svelte'; diff --git a/tools/server/webui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte b/tools/server/webui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte deleted file mode 100644 index 109c8ff9d..000000000 --- a/tools/server/webui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte +++ /dev/null @@ -1,175 +0,0 @@ - - -
        -
        - section.slug === activeSlug} - getHref={getSectionHref ?? - ((section: SettingsSection) => RouterService.settings(section.slug))} - /> - - section.slug === activeSlug} - getHref={getSectionHref ?? - ((section: SettingsSection) => RouterService.settings(section.slug))} - bind:this={mobileHeader} - /> - -
        -
        -
        -
        - -

        {currentSection.title}

        -
        - - {#if currentSection.title === SETTINGS_SECTION_TITLES.TOOLS} - - {:else if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT} - - {:else if currentSection.fields} -
        - -
        - {/if} -
        - -
        -

        Settings are saved in browser's localStorage

        -
        -
        - - -
        -
        -
        diff --git a/tools/server/webui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte b/tools/server/webui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte deleted file mode 100644 index 3ecf00adc..000000000 --- a/tools/server/webui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte +++ /dev/null @@ -1,265 +0,0 @@ - - -{#each fields as field (field.key)} -
        - {#if field.type === SettingsFieldType.INPUT} - {@const currentValue = String(localConfig[field.key] ?? '')} - {@const serverDefault = currentModelParams[field.key]} - {@const isCustomRealTime = (() => { - if (serverDefault == null) return false; - if (currentValue === '') return false; - - const numericInput = parseFloat(currentValue); - const normalizedInput = !isNaN(numericInput) - ? Math.round(numericInput * 1000000) / 1000000 - : currentValue; - const normalizedDefault = - typeof serverDefault === 'number' - ? Math.round(serverDefault * 1000000) / 1000000 - : serverDefault; - - return normalizedInput !== normalizedDefault; - })()} - -
        - - {#if isCustomRealTime} - - {/if} -
        - -
        - { - // Update local config immediately for real-time badge feedback - onConfigChange(field.key, e.currentTarget.value); - }} - placeholder={currentModelParams[field.key] != null - ? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}` - : ''} - class="w-full {isCustomRealTime ? 'pr-8' : ''}" - /> - {#if isCustomRealTime} - - {/if} -
        - {#if field.help || SETTING_CONFIG_INFO[field.key]} -

        - {@html field.help || SETTING_CONFIG_INFO[field.key]} -

        - {/if} - {:else if field.type === SettingsFieldType.TEXTAREA} - {#if field.label} - - {/if} - - diff --git a/tools/server/webui/src/lib/components/ui/tooltip/index.ts b/tools/server/webui/src/lib/components/ui/tooltip/index.ts deleted file mode 100644 index 273d831e6..000000000 --- a/tools/server/webui/src/lib/components/ui/tooltip/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Tooltip as TooltipPrimitive } from 'bits-ui'; -import Trigger from './tooltip-trigger.svelte'; -import Content from './tooltip-content.svelte'; - -const Root = TooltipPrimitive.Root; -const Provider = TooltipPrimitive.Provider; -const Portal = TooltipPrimitive.Portal; - -export { - Root, - Trigger, - Content, - Provider, - Portal, - // - Root as Tooltip, - Content as TooltipContent, - Trigger as TooltipTrigger, - Provider as TooltipProvider, - Portal as TooltipPortal -}; diff --git a/tools/server/webui/src/lib/components/ui/tooltip/tooltip-content.svelte b/tools/server/webui/src/lib/components/ui/tooltip/tooltip-content.svelte deleted file mode 100644 index 5b0c76818..000000000 --- a/tools/server/webui/src/lib/components/ui/tooltip/tooltip-content.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - -{#snippet tooltipContent()} - - {@render children?.()} - - {#snippet child({ props })} -
        - {/snippet} -
        -
        -{/snippet} - -{#if noPortal} - {@render tooltipContent()} -{:else} - - {@render tooltipContent()} - -{/if} diff --git a/tools/server/webui/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/tools/server/webui/src/lib/components/ui/tooltip/tooltip-trigger.svelte deleted file mode 100644 index 671d6e220..000000000 --- a/tools/server/webui/src/lib/components/ui/tooltip/tooltip-trigger.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/tools/server/webui/src/lib/components/ui/utils.ts b/tools/server/webui/src/lib/components/ui/utils.ts deleted file mode 100644 index f92bfcbb3..000000000 --- a/tools/server/webui/src/lib/components/ui/utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { clsx, type ClassValue } from 'clsx'; -import { twMerge } from 'tailwind-merge'; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type WithoutChild = T extends { child?: any } ? Omit : T; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type WithoutChildren = T extends { children?: any } ? Omit : T; -export type WithoutChildrenOrChild = WithoutChildren>; -export type WithElementRef = T & { ref?: U | null }; diff --git a/tools/server/webui/src/lib/constants/agentic.ts b/tools/server/webui/src/lib/constants/agentic.ts deleted file mode 100644 index c0575163e..000000000 --- a/tools/server/webui/src/lib/constants/agentic.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { AgenticConfig } from '$lib/types/agentic'; - -export const ATTACHMENT_SAVED_REGEX = /\[Attachment saved: ([^\]]+)\]/; - -export const NEWLINE_SEPARATOR = '\n'; - -export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = { - enabled: true, - maxTurns: 100, - maxToolPreviewLines: 25 -} as const; - -export const REASONING_TAGS = { - START: '', - END: '' -} as const; - -/** - * @deprecated Legacy marker tags - only used for migration of old stored messages. - * New messages use structured fields (reasoningContent, toolCalls, toolCallId). - */ -export const LEGACY_AGENTIC_TAGS = { - TOOL_CALL_START: '<<>>', - TOOL_CALL_END: '<<>>', - TOOL_NAME_PREFIX: '<<>>', - TOOL_ARGS_END: '<<>>', - TAG_SUFFIX: '>>>' -} as const; - -/** - * @deprecated Legacy reasoning tags - only used for migration of old stored messages. - * New messages use the dedicated reasoningContent field. - */ -export const LEGACY_REASONING_TAGS = { - START: '<<>>', - END: '<<>>' -} as const; - -/** - * @deprecated Legacy regex patterns - only used for migration of old stored messages. - */ -export const LEGACY_AGENTIC_REGEX = { - COMPLETED_TOOL_CALL: - /<<>>\n<<>>\n<<>>([\s\S]*?)<<>>([\s\S]*?)<<>>/g, - REASONING_BLOCK: /<<>>[\s\S]*?<<>>/g, - REASONING_EXTRACT: /<<>>([\s\S]*?)<<>>/, - REASONING_OPEN: /<<>>[\s\S]*$/, - AGENTIC_TOOL_CALL_BLOCK: /\n*<<>>[\s\S]*?<<>>/g, - AGENTIC_TOOL_CALL_OPEN: /\n*<<>>[\s\S]*$/, - HAS_LEGACY_MARKERS: /<<<(?:AGENTIC_TOOL_CALL_START|reasoning_content_start)>>>/ -} as const; diff --git a/tools/server/webui/src/lib/constants/api-endpoints.ts b/tools/server/webui/src/lib/constants/api-endpoints.ts deleted file mode 100644 index f89ebe421..000000000 --- a/tools/server/webui/src/lib/constants/api-endpoints.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const API_MODELS = { - LIST: '/v1/models', - LOAD: '/models/load', - UNLOAD: '/models/unload' -}; - -export const API_TOOLS = { - LIST: '/tools', - EXECUTE: '/tools' -}; - -/** CORS proxy endpoint path */ -export const CORS_PROXY_ENDPOINT = '/cors-proxy'; diff --git a/tools/server/webui/src/lib/constants/attachment-labels.ts b/tools/server/webui/src/lib/constants/attachment-labels.ts deleted file mode 100644 index be9999c0f..000000000 --- a/tools/server/webui/src/lib/constants/attachment-labels.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const ATTACHMENT_LABEL_FILE = 'File'; -export const ATTACHMENT_LABEL_PDF_FILE = 'PDF File'; -export const ATTACHMENT_LABEL_MCP_PROMPT = 'MCP Prompt'; -export const ATTACHMENT_LABEL_MCP_RESOURCE = 'MCP Resource'; diff --git a/tools/server/webui/src/lib/constants/attachment-menu.ts b/tools/server/webui/src/lib/constants/attachment-menu.ts deleted file mode 100644 index dea4d1a39..000000000 --- a/tools/server/webui/src/lib/constants/attachment-menu.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { Component } from 'svelte'; -import { MessageSquare, Zap, FolderOpen } from '@lucide/svelte'; -import { FILE_TYPE_ICONS } from '$lib/constants/icons'; -import { - AttachmentAction, - AttachmentItemEnabledWhen, - AttachmentItemVisibleWhen, - AttachmentMenuItemId -} from '$lib/enums'; - -export interface AttachmentMenuItem { - /** Unique identifier for the item */ - id: AttachmentMenuItemId; - /** Display label */ - label: string; - /** Lucide icon component */ - icon: Component; - /** Extra CSS class applied to the item (e.g. for test selectors) */ - class?: string; - /** Whether the item requires a specific modality to be enabled */ - enabledWhen?: AttachmentItemEnabledWhen; - /** Tooltip shown when the item is disabled */ - disabledTooltip?: string; - /** Callback key on the Props interface to invoke when clicked */ - action: AttachmentAction; - /** Whether the item is only shown when a specific capability is present */ - visibleWhen?: AttachmentItemVisibleWhen; - /** Whether this item has a tooltip even when enabled (uses dynamic text) */ - hasEnabledTooltip?: boolean; -} - -/** - * File attachment menu items shown in both the desktop dropdown and mobile sheet. - * The "Tools" submenu is handled separately by each component. - */ -export const ATTACHMENT_FILE_ITEMS: AttachmentMenuItem[] = [ - { - id: AttachmentMenuItemId.IMAGES, - label: 'Images', - icon: FILE_TYPE_ICONS.image, - class: 'images-button', - enabledWhen: AttachmentItemEnabledWhen.HAS_VISION_MODALITY, - disabledTooltip: 'Image processing requires a vision model', - action: AttachmentAction.FILE_UPLOAD - }, - { - id: AttachmentMenuItemId.AUDIO, - label: 'Audio Files', - icon: FILE_TYPE_ICONS.audio, - class: 'audio-button', - enabledWhen: AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY, - disabledTooltip: 'Audio files processing requires an audio model', - action: AttachmentAction.FILE_UPLOAD - }, - { - id: AttachmentMenuItemId.TEXT, - label: 'Text Files', - icon: FILE_TYPE_ICONS.text, - enabledWhen: AttachmentItemEnabledWhen.ALWAYS, - action: AttachmentAction.FILE_UPLOAD - }, - { - id: AttachmentMenuItemId.PDF, - label: 'PDF Files', - icon: FILE_TYPE_ICONS.pdf, - enabledWhen: AttachmentItemEnabledWhen.ALWAYS, - disabledTooltip: 'PDFs will be converted to text. Image-based PDFs may not work properly.', - hasEnabledTooltip: true, - action: AttachmentAction.FILE_UPLOAD - } -]; - -export const ATTACHMENT_EXTRA_ITEMS: AttachmentMenuItem[] = [ - { - id: AttachmentMenuItemId.SYSTEM_MESSAGE, - label: 'System Message', - icon: MessageSquare, - enabledWhen: AttachmentItemEnabledWhen.ALWAYS, - hasEnabledTooltip: true, - action: AttachmentAction.SYSTEM_PROMPT_CLICK - } -]; - -export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [ - { - id: AttachmentMenuItemId.MCP_PROMPT, - label: 'MCP Prompt', - icon: Zap, - enabledWhen: AttachmentItemEnabledWhen.ALWAYS, - action: AttachmentAction.MCP_PROMPT_CLICK, - visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_PROMPTS_SUPPORT - }, - { - id: AttachmentMenuItemId.MCP_RESOURCES, - label: 'MCP Resources', - icon: FolderOpen, - enabledWhen: AttachmentItemEnabledWhen.ALWAYS, - action: AttachmentAction.MCP_RESOURCES_CLICK, - visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_RESOURCES_SUPPORT - } -]; - -export const ATTACHMENT_TOOLTIP_TEXT = 'Add files, prompts, tools or MCP Servers'; diff --git a/tools/server/webui/src/lib/constants/auto-scroll.ts b/tools/server/webui/src/lib/constants/auto-scroll.ts deleted file mode 100644 index ca9ba5a9e..000000000 --- a/tools/server/webui/src/lib/constants/auto-scroll.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const AUTO_SCROLL_INTERVAL = 100; -export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10; diff --git a/tools/server/webui/src/lib/constants/binary-detection.ts b/tools/server/webui/src/lib/constants/binary-detection.ts deleted file mode 100644 index 21a95cc88..000000000 --- a/tools/server/webui/src/lib/constants/binary-detection.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { BinaryDetectionOptions } from '$lib/types'; - -export const DEFAULT_BINARY_DETECTION_OPTIONS: BinaryDetectionOptions = { - prefixLength: 1024 * 10, // Check the first 10KB of the string - suspiciousCharThresholdRatio: 0.15, // Allow up to 15% suspicious chars - maxAbsoluteNullBytes: 2 -}; diff --git a/tools/server/webui/src/lib/constants/cache.ts b/tools/server/webui/src/lib/constants/cache.ts deleted file mode 100644 index 07fe86834..000000000 --- a/tools/server/webui/src/lib/constants/cache.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Cache configuration constants - */ - -/** - * Default TTL (Time-To-Live) for cache entries in milliseconds - * @default 5 minutes - */ -export const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; - -/** - * Default maximum number of entries in a cache - * @default 100 - */ -export const DEFAULT_CACHE_MAX_ENTRIES = 100; - -/** - * TTL for model props cache in milliseconds - * Props don't change frequently, so we can cache them longer - * @default 10 minutes - */ -export const MODEL_PROPS_CACHE_TTL_MS = 10 * 60 * 1000; - -/** - * Maximum number of model props to cache - * @default 50 - */ -export const MODEL_PROPS_CACHE_MAX_ENTRIES = 50; - -/** - * Maximum number of MCP resources to cache - * @default 50 - */ -export const MCP_RESOURCE_CACHE_MAX_ENTRIES = 50; - -/** - * TTL for MCP resource cache entries in milliseconds - * @default 5 minutes - */ -export const MCP_RESOURCE_CACHE_TTL_MS = 5 * 60 * 1000; - -/** - * Maximum number of inactive conversation states to keep in memory - * States for conversations beyond this limit will be cleaned up - * @default 10 - */ -export const MAX_INACTIVE_CONVERSATION_STATES = 10; - -/** - * Maximum age (in ms) for inactive conversation states before cleanup - * States older than this will be removed during cleanup - * @default 30 minutes - */ -export const INACTIVE_CONVERSATION_STATE_MAX_AGE_MS = 30 * 60 * 1000; diff --git a/tools/server/webui/src/lib/constants/chat-form.ts b/tools/server/webui/src/lib/constants/chat-form.ts deleted file mode 100644 index 05ab8c1f8..000000000 --- a/tools/server/webui/src/lib/constants/chat-form.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const INITIAL_FILE_SIZE = 0; -export const PROMPT_CONTENT_SEPARATOR = '\n\n'; -export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"'; -export const PROMPT_TRIGGER_PREFIX = '/'; -export const RESOURCE_TRIGGER_PREFIX = '@'; -export const NEW_CHAT_DRAFT_KEY = '__new_chat__'; diff --git a/tools/server/webui/src/lib/constants/code-blocks.ts b/tools/server/webui/src/lib/constants/code-blocks.ts deleted file mode 100644 index 0f7265104..000000000 --- a/tools/server/webui/src/lib/constants/code-blocks.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const CODE_BLOCK_SCROLL_CONTAINER_CLASS = 'code-block-scroll-container'; -export const CODE_BLOCK_WRAPPER_CLASS = 'code-block-wrapper'; -export const CODE_BLOCK_HEADER_CLASS = 'code-block-header'; -export const CODE_BLOCK_ACTIONS_CLASS = 'code-block-actions'; -export const CODE_LANGUAGE_CLASS = 'code-language'; -export const COPY_CODE_BTN_CLASS = 'copy-code-btn'; -export const PREVIEW_CODE_BTN_CLASS = 'preview-code-btn'; -export const RELATIVE_CLASS = 'relative'; diff --git a/tools/server/webui/src/lib/constants/code.ts b/tools/server/webui/src/lib/constants/code.ts deleted file mode 100644 index 12bcd0db7..000000000 --- a/tools/server/webui/src/lib/constants/code.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const NEWLINE = '\n'; -export const DEFAULT_LANGUAGE = 'text'; -export const LANG_PATTERN = /^(\w*)\n?/; -export const AMPERSAND_REGEX = /&/g; -export const LT_REGEX = //g; -export const FENCE_PATTERN = /^```|\n```/g; diff --git a/tools/server/webui/src/lib/constants/context-keys.ts b/tools/server/webui/src/lib/constants/context-keys.ts deleted file mode 100644 index 12de0d0bc..000000000 --- a/tools/server/webui/src/lib/constants/context-keys.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const CONTEXT_KEY_MESSAGE_EDIT = 'chat-message-edit'; -export const CONTEXT_KEY_CHAT_ACTIONS = 'chat-actions'; -export const CONTEXT_KEY_CHAT_SETTINGS_CONFIG = 'chat-settings-config'; -export const CONTEXT_KEY_PROCESSING_INFO = 'processing-info'; diff --git a/tools/server/webui/src/lib/constants/css-classes.ts b/tools/server/webui/src/lib/constants/css-classes.ts deleted file mode 100644 index ca5386fcd..000000000 --- a/tools/server/webui/src/lib/constants/css-classes.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const BOX_BORDER = - 'border border-border/30 focus-within:border-border dark:border-border/20 dark:focus-within:border-border'; - -export const INPUT_CLASSES = ` - bg-muted/60 dark:bg-muted/75 - ${BOX_BORDER} - shadow-sm - outline-none - text-foreground -`; - -export const PANEL_CLASSES = ` - bg-background - border border-border/30 dark:border-border/20 - shadow-sm backdrop-blur-lg! - rounded-t-lg! -`; - -export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80'; diff --git a/tools/server/webui/src/lib/constants/floating-ui-constraints.ts b/tools/server/webui/src/lib/constants/floating-ui-constraints.ts deleted file mode 100644 index 003fc77ac..000000000 --- a/tools/server/webui/src/lib/constants/floating-ui-constraints.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const VIEWPORT_GUTTER = 8; -export const MENU_OFFSET = 6; diff --git a/tools/server/webui/src/lib/constants/formatters.ts b/tools/server/webui/src/lib/constants/formatters.ts deleted file mode 100644 index d6d1b883f..000000000 --- a/tools/server/webui/src/lib/constants/formatters.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const MS_PER_SECOND = 1000; -export const SECONDS_PER_MINUTE = 60; -export const SECONDS_PER_HOUR = 3600; -export const SHORT_DURATION_THRESHOLD = 1; -export const MEDIUM_DURATION_THRESHOLD = 10; - -/** Default display value when no performance time is available */ -export const DEFAULT_PERFORMANCE_TIME = '0s'; diff --git a/tools/server/webui/src/lib/constants/icons.ts b/tools/server/webui/src/lib/constants/icons.ts deleted file mode 100644 index 1e88ab5b3..000000000 --- a/tools/server/webui/src/lib/constants/icons.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Icon mappings for file types and model modalities - * Centralized configuration to ensure consistent icon usage across the app - */ - -import { - File as FileIcon, - FileText as FileTextIcon, - Image as ImageIcon, - Eye as VisionIcon, - Mic as AudioIcon -} from '@lucide/svelte'; -import { FileTypeCategory, ModelModality } from '$lib/enums'; - -export const FILE_TYPE_ICONS = { - [FileTypeCategory.IMAGE]: ImageIcon, - [FileTypeCategory.AUDIO]: AudioIcon, - [FileTypeCategory.TEXT]: FileTextIcon, - [FileTypeCategory.PDF]: FileIcon -} as const; - -export const DEFAULT_FILE_ICON = FileIcon; - -export const MODALITY_ICONS = { - [ModelModality.VISION]: VisionIcon, - [ModelModality.AUDIO]: AudioIcon -} as const; - -export const MODALITY_LABELS = { - [ModelModality.VISION]: 'Vision', - [ModelModality.AUDIO]: 'Audio' -} as const; diff --git a/tools/server/webui/src/lib/constants/index.ts b/tools/server/webui/src/lib/constants/index.ts deleted file mode 100644 index 88ff43e56..000000000 --- a/tools/server/webui/src/lib/constants/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Central constants export file -// All constants should be imported from '$lib/constants' - -export * from './agentic'; -export * from './api-endpoints'; -export * from './attachment-labels'; -export * from './attachment-menu'; -export * from './auto-scroll'; -export * from './binary-detection'; -export * from './cache'; -export * from './chat-form'; -export * from './code-blocks'; -export * from './code'; -export * from './context-keys'; -export * from './css-classes'; -export * from './floating-ui-constraints'; -export * from './formatters'; -export * from './key-value-pairs'; -export * from './icons'; -export * from './latex-protection'; -export * from './literal-html'; -export * from './localstorage-keys'; -export * from './markdown'; -export * from './max-bundle-size'; -export * from './mcp'; -export * from './mcp-form'; -export * from './mcp-resource'; -export * from './message-export'; -export * from './model-id'; -export * from './precision'; -export * from './processing-info'; -export * from './routes'; -export * from './settings-keys'; -export * from './settings-registry'; -export * from './supported-file-types'; -export * from './table-html-restorer'; -export * from './title-generation'; -export * from './tools'; -export * from './tooltip-config'; -export * from './ui'; -export * from './uri-template'; -export * from './url'; -export * from './viewport'; diff --git a/tools/server/webui/src/lib/constants/key-value-pairs.ts b/tools/server/webui/src/lib/constants/key-value-pairs.ts deleted file mode 100644 index 48dadbec4..000000000 --- a/tools/server/webui/src/lib/constants/key-value-pairs.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Key-value pair form constraints and sanitization patterns. - * - * Both regexes target characters dangerous in HTTP-header / env-var contexts: - * \x00 – null byte (injection) - * \x0A (\n) – LF (HTTP header injection / response splitting) - * \x0D (\r) – CR (HTTP header injection / response splitting) - * \x01–\x08, \x0B–\x0C, \x0E–\x1F, \x7F – other C0/DEL control chars - * - * KEY_UNSAFE_RE additionally strips TAB (\x09); values keep TAB because it is - * a valid header-value continuation character per RFC 7230. - */ - -export const KEY_VALUE_PAIR_KEY_MAX_LENGTH = 256; -export const KEY_VALUE_PAIR_VALUE_MAX_LENGTH = 8192; - -// eslint-disable-next-line no-control-regex -export const KEY_VALUE_PAIR_UNSAFE_KEY_RE = /[\x00-\x1F\x7F]/g; -// eslint-disable-next-line no-control-regex -export const KEY_VALUE_PAIR_UNSAFE_VALUE_RE = /[\x00-\x08\x0A-\x0D\x0E-\x1F\x7F]/g; diff --git a/tools/server/webui/src/lib/constants/latex-protection.ts b/tools/server/webui/src/lib/constants/latex-protection.ts deleted file mode 100644 index 27c88e725..000000000 --- a/tools/server/webui/src/lib/constants/latex-protection.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Matches common Markdown code blocks to exclude them from further processing (e.g. LaTeX). - * - Fenced: ```...``` - * - Inline: `...` (does NOT support nested backticks or multi-backtick syntax) - * - * Note: This pattern does not handle advanced cases like: - * `` `code with `backticks` `` or \\``...\\`` - */ -export const CODE_BLOCK_REGEXP = /(```[\s\S]*?```|`[^`\n]+`)/g; - -/** - * Matches LaTeX math delimiters \(...\) and \[...\] only when not preceded by a backslash (i.e., not escaped), - * while also capturing code blocks (```, `...`) so they can be skipped during processing. - * - * Uses negative lookbehind `(? = { - [MimeTypeImage.JPEG]: 'jpg', - [MimeTypeImage.JPG]: 'jpg', - [MimeTypeImage.PNG]: 'png', - [MimeTypeImage.GIF]: 'gif', - [MimeTypeImage.WEBP]: 'webp' -} as const; diff --git a/tools/server/webui/src/lib/constants/mcp.ts b/tools/server/webui/src/lib/constants/mcp.ts deleted file mode 100644 index ea8ad3456..000000000 --- a/tools/server/webui/src/lib/constants/mcp.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Zap, Globe, Radio } from '@lucide/svelte'; -import { MCPTransportType } from '$lib/enums'; -import type { ClientCapabilities, Implementation } from '$lib/types'; -import type { Component } from 'svelte'; -import { MimeTypeImage } from '$lib/enums/files'; - -export const DEFAULT_CLIENT_VERSION = '1.0.0'; -export const DEFAULT_IMAGE_MIME_TYPE = MimeTypeImage.PNG; - -/** MIME types considered safe for rendering MCP server icons */ -export const MCP_ALLOWED_ICON_MIME_TYPES = new Set([ - MimeTypeImage.PNG, - MimeTypeImage.JPEG, - MimeTypeImage.JPG, - MimeTypeImage.SVG, - MimeTypeImage.WEBP, - MimeTypeImage.ICO, - MimeTypeImage.ICO_MICROSOFT -]); - -/** - * MCP specification version this client targets. - * Update when the upstream MCP spec introduces a new stable version: - * https://spec.modelcontextprotocol.io/ - */ -export const MCP_PROTOCOL_VERSION = '2025-06-18'; - -export const DEFAULT_MCP_CONFIG = { - protocolVersion: MCP_PROTOCOL_VERSION, - capabilities: { tools: { listChanged: true } } as ClientCapabilities, - clientInfo: { name: 'llama-webui-mcp', version: DEFAULT_CLIENT_VERSION } as Implementation, - requestTimeoutSeconds: 300, // 5 minutes for long-running tools - connectionTimeoutMs: 10_000 // 10 seconds for connection establishment -} as const; - -export const MCP_SERVER_ID_PREFIX = 'LlamaCpp-WebUI-MCP-Server'; - -export const MCP_RECONNECT_INITIAL_DELAY = 1000; -export const MCP_RECONNECT_BACKOFF_MULTIPLIER = 2; -export const MCP_RECONNECT_MAX_DELAY = 30000; -/** Per-attempt timeout for a single reconnection attempt before giving up and backing off. */ -export const MCP_RECONNECT_ATTEMPT_TIMEOUT_MS = 15_000; - -/** Maximum number of MCP server avatars to display in the chat form */ -export const MAX_DISPLAYED_MCP_AVATARS = 4; - -/** Expected count when two theme-less icons represent a light/dark pair */ -export const EXPECTED_THEMED_ICON_PAIR_COUNT = 2; - -/** CORS proxy URL query parameter name */ -export const CORS_PROXY_URL_PARAM = 'url'; - -/** Number of trailing characters to keep visible when partially redacting mcp-session-id */ -export const MCP_SESSION_ID_VISIBLE_CHARS = 5; - -/** Partial-redaction rules for MCP headers: header name -> visible trailing chars */ -export const MCP_PARTIAL_REDACT_HEADERS = new Map([ - ['mcp-session-id', MCP_SESSION_ID_VISIBLE_CHARS] -]); - -/** Header names whose values should be redacted in diagnostic logs */ -export const REDACTED_HEADERS = new Set([ - 'authorization', - 'api-key', - 'cookie', - 'mcp-session-id', - 'proxy-authorization', - 'set-cookie', - 'x-auth-token', - 'x-api-key' -]); - -/** Human-readable labels for MCP transport types */ -export const MCP_TRANSPORT_LABELS: Record = { - [MCPTransportType.WEBSOCKET]: 'WebSocket', - [MCPTransportType.STREAMABLE_HTTP]: 'HTTP', - [MCPTransportType.SSE]: 'SSE' -}; - -/** Icon components for MCP transport types */ -export const MCP_TRANSPORT_ICONS: Record = { - [MCPTransportType.WEBSOCKET]: Zap, - [MCPTransportType.STREAMABLE_HTTP]: Globe, - [MCPTransportType.SSE]: Radio -}; diff --git a/tools/server/webui/src/lib/constants/message-export.ts b/tools/server/webui/src/lib/constants/message-export.ts deleted file mode 100644 index 79fa36f91..000000000 --- a/tools/server/webui/src/lib/constants/message-export.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Conversation filename constants - -// Length of the trimmed conversation ID in the filename -export const EXPORT_CONV_ID_TRIM_LENGTH = 8; -// Maximum length of the sanitized conversation name snippet -export const EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH = 20; -// Characters to keep in the ISO timestamp. 19 keeps 2026-01-01T00:00:00 -export const ISO_TIMESTAMP_SLICE_LENGTH = 19; - -// Replacements for making the conversation title filename-friendly -export const NON_ALPHANUMERIC_REGEX = /[^a-z0-9]/gi; -export const EXPORT_CONV_NONALNUM_REPLACEMENT = '_'; -export const MULTIPLE_UNDERSCORE_REGEX = /_+/g; - -// Replacements to the ISO date for use in the export filename -export const ISO_DATE_TIME_SEPARATOR = 'T'; -export const ISO_DATE_TIME_SEPARATOR_REPLACEMENT = '_'; - -export const ISO_TIME_SEPARATOR = ':'; -export const ISO_TIME_SEPARATOR_REPLACEMENT = '-'; diff --git a/tools/server/webui/src/lib/constants/model-id.ts b/tools/server/webui/src/lib/constants/model-id.ts deleted file mode 100644 index ee314d167..000000000 --- a/tools/server/webui/src/lib/constants/model-id.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** Sentinel value returned by `indexOf` when a substring is not found. */ -export const MODEL_ID_NOT_FOUND = -1; - -/** Separates `` from `` in a model ID, e.g. `org/ModelName`. */ -export const MODEL_ID_ORG_SEPARATOR = '/'; - -/** Separates named segments within the model path, e.g. `ModelName-7B-GGUF`. */ -export const MODEL_ID_SEGMENT_SEPARATOR = '-'; - -/** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */ -export const MODEL_ID_QUANTIZATION_SEPARATOR = ':'; - -/** - * Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `F16`, `BF16`, `MXFP4`. - * Case-insensitive to handle both uppercase and lowercase inputs. - */ -export const MODEL_QUANTIZATION_SEGMENT_RE = - /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i; - -/** - * Matches prefix for custom quantization types, e.g. `UD-Q8_K_XL`. - */ -export const MODEL_CUSTOM_QUANTIZATION_PREFIX_RE = /^UD$/i; - -/** - * Matches a parameter-count segment, e.g. `7B`, `1.5b`, `120M`. - */ -export const MODEL_PARAMS_RE = /^\d+(\.\d+)?[BbMmKkTt]$/; - -/** - * Matches an activated-parameter-count segment, e.g. `A10B`, `a2.4b`. - * The leading `A`/`a` distinguishes it from a regular params segment. - */ -export const MODEL_ACTIVATED_PARAMS_RE = /^[Aa]\d+(\.\d+)?[BbMmKkTt]$/; - -/** - * Container format segments to exclude from tags (every model uses these). - */ -export const MODEL_IGNORED_SEGMENTS = new Set(['GGUF', 'GGML']); diff --git a/tools/server/webui/src/lib/constants/precision.ts b/tools/server/webui/src/lib/constants/precision.ts deleted file mode 100644 index 8df5c4f96..000000000 --- a/tools/server/webui/src/lib/constants/precision.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const PRECISION_MULTIPLIER = 1000000; -export const PRECISION_DECIMAL_PLACES = 6; diff --git a/tools/server/webui/src/lib/constants/processing-info.ts b/tools/server/webui/src/lib/constants/processing-info.ts deleted file mode 100644 index 2c3f7dc53..000000000 --- a/tools/server/webui/src/lib/constants/processing-info.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const PROCESSING_INFO_TIMEOUT = 2000; - -/** - * Statistics units labels - */ -export const STATS_UNITS = { - TOKENS_PER_SECOND: 't/s' -} as const; diff --git a/tools/server/webui/src/lib/constants/routes.ts b/tools/server/webui/src/lib/constants/routes.ts deleted file mode 100644 index 14416478f..000000000 --- a/tools/server/webui/src/lib/constants/routes.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const NEW_CHAT_PARAM = 'new_chat'; - -/** Settings section slugs — used for routes and navigation. */ -export const SETTINGS_SECTION_SLUGS = { - GENERAL: 'general', - DISPLAY: 'display', - SAMPLING: 'sampling', - PENALTIES: 'penalties', - AGENTIC: 'agentic', - DEVELOPER: 'developer', - TOOLS: 'tools', - IMPORT_EXPORT: 'import-export' -} as const; - -export const ROUTES = { - /** Root — start of the app. */ - START: '#/', - /** New chat — root with new chat query param. */ - NEW_CHAT: `?${NEW_CHAT_PARAM}=true#/`, - /** Chat base — for dynamic chat URLs use RouterService. */ - CHAT: '#/chat', - /** MCP servers. */ - MCP_SERVERS: '#/mcp-servers', - /** Settings base — for dynamic settings URLs use RouterService. */ - SETTINGS: '#/settings' -} as const; diff --git a/tools/server/webui/src/lib/constants/settings-keys.ts b/tools/server/webui/src/lib/constants/settings-keys.ts deleted file mode 100644 index b673bff27..000000000 --- a/tools/server/webui/src/lib/constants/settings-keys.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Settings key constants for ChatSettings configuration. - * - * These keys correspond to properties in SettingsConfigType and are used - * in settings field configurations to ensure consistency. - */ -export const SETTINGS_KEYS = { - // General - THEME: 'theme', - API_KEY: 'apiKey', - SYSTEM_MESSAGE: 'systemMessage', - PASTE_LONG_TEXT_TO_FILE_LEN: 'pasteLongTextToFileLen', - COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT: 'copyTextAttachmentsAsPlainText', - SEND_ON_ENTER: 'sendOnEnter', - ENABLE_CONTINUE_GENERATION: 'enableContinueGeneration', - PDF_AS_IMAGE: 'pdfAsImage', - ASK_FOR_TITLE_CONFIRMATION: 'askForTitleConfirmation', - TITLE_GENERATION_USE_FIRST_LINE: 'titleGenerationUseFirstLine', - TITLE_GENERATION_USE_LLM: 'titleGenerationUseLLM', - TITLE_GENERATION_PROMPT: 'titleGenerationPrompt', - // Display - SHOW_MESSAGE_STATS: 'showMessageStats', - SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress', - KEEP_STATS_VISIBLE: 'keepStatsVisible', - AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty', - RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown', - DISABLE_AUTO_SCROLL: 'disableAutoScroll', - ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop', - FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks', - SHOW_RAW_MODEL_NAMES: 'showRawModelNames', - SHOW_SYSTEM_MESSAGE: 'showSystemMessage', - // Sampling - TEMPERATURE: 'temperature', - DYNATEMP_RANGE: 'dynatemp_range', - DYNATEMP_EXPONENT: 'dynatemp_exponent', - TOP_K: 'top_k', - TOP_P: 'top_p', - MIN_P: 'min_p', - XTC_PROBABILITY: 'xtc_probability', - XTC_THRESHOLD: 'xtc_threshold', - TYP_P: 'typ_p', - MAX_TOKENS: 'max_tokens', - SAMPLERS: 'samplers', - BACKEND_SAMPLING: 'backend_sampling', - // Penalties - REPEAT_LAST_N: 'repeat_last_n', - REPEAT_PENALTY: 'repeat_penalty', - PRESENCE_PENALTY: 'presence_penalty', - FREQUENCY_PENALTY: 'frequency_penalty', - DRY_MULTIPLIER: 'dry_multiplier', - DRY_BASE: 'dry_base', - DRY_ALLOWED_LENGTH: 'dry_allowed_length', - DRY_PENALTY_LAST_N: 'dry_penalty_last_n', - // MCP - MCP_SERVERS: 'mcpServers', - AGENTIC_MAX_TURNS: 'agenticMaxTurns', - ALWAYS_SHOW_AGENTIC_TURNS: 'alwaysShowAgenticTurns', - AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines', - SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress', - // Performance - PRE_ENCODE_CONVERSATION: 'preEncodeConversation', - // Developer - DISABLE_REASONING_PARSING: 'disableReasoningParsing', - EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext', - SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch', - // PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled', - CUSTOM: 'custom' -} as const; diff --git a/tools/server/webui/src/lib/constants/settings-registry.ts b/tools/server/webui/src/lib/constants/settings-registry.ts deleted file mode 100644 index 809f78064..000000000 --- a/tools/server/webui/src/lib/constants/settings-registry.ts +++ /dev/null @@ -1,719 +0,0 @@ -import { ColorMode } from '$lib/enums/ui'; -import { SettingsFieldType } from '$lib/enums/settings'; -import { SyncableParameterType } from '$lib/enums'; -import { - Funnel, - AlertTriangle, - Code, - Monitor, - ListRestart, - Sliders, - PencilRuler, - Database, - Monitor as MonitorIcon, - Sun, - Moon -} from '@lucide/svelte'; -import type { Component } from 'svelte'; -import type { - SettingsConfigValue, - SyncableParameter, - SettingsEntry, - SettingsSectionTitle, - SettingsSectionEntry, - SettingsSection -} from '$lib/types'; -import { SETTINGS_KEYS } from './settings-keys'; -import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes'; -import { TITLE_GENERATION } from './title-generation'; - -export const SETTINGS_SECTION_TITLES = { - GENERAL: 'General', - DISPLAY: 'Display', - SAMPLING: 'Sampling', - PENALTIES: 'Penalties', - AGENTIC: 'Agentic', - TOOLS: 'Tools', - IMPORT_EXPORT: 'Import/Export', - DEVELOPER: 'Developer' -} as const; - -const STANDALONE_SECTIONS: { title: SettingsSectionTitle; slug: string; icon: Component }[] = [ - { title: SETTINGS_SECTION_TITLES.TOOLS, slug: SETTINGS_SECTION_SLUGS.TOOLS, icon: PencilRuler }, - { - title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT, - slug: SETTINGS_SECTION_SLUGS.IMPORT_EXPORT, - icon: Database - } -]; - -const COLOR_MODE_OPTIONS: Array<{ value: string; label: string; icon: Component }> = [ - { value: ColorMode.SYSTEM, label: 'System', icon: MonitorIcon }, - { value: ColorMode.LIGHT, label: 'Light', icon: Sun }, - { value: ColorMode.DARK, label: 'Dark', icon: Moon } -]; - -const SETTINGS_REGISTRY: Record = { - [SETTINGS_SECTION_SLUGS.GENERAL]: { - title: SETTINGS_SECTION_TITLES.GENERAL, - slug: SETTINGS_SECTION_SLUGS.GENERAL, - icon: Sliders, - settings: [ - { - key: SETTINGS_KEYS.THEME, - label: 'Theme', - help: 'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.', - defaultValue: ColorMode.SYSTEM, - type: SettingsFieldType.SELECT, - section: SETTINGS_SECTION_SLUGS.GENERAL, - options: COLOR_MODE_OPTIONS, - sync: { serverKey: SETTINGS_KEYS.THEME, paramType: SyncableParameterType.STRING } - }, - { - key: SETTINGS_KEYS.API_KEY, - label: 'API Key', - help: 'Set the API Key if you are using --api-key option for the server.', - defaultValue: '', - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.GENERAL - }, - { - key: SETTINGS_KEYS.SYSTEM_MESSAGE, - label: 'System Message', - help: 'The starting message that defines how model should behave.', - defaultValue: '', - type: SettingsFieldType.TEXTAREA, - section: SETTINGS_SECTION_SLUGS.GENERAL, - sync: { serverKey: SETTINGS_KEYS.SYSTEM_MESSAGE, paramType: SyncableParameterType.STRING } - }, - { - key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN, - label: 'Paste long text to file length', - help: 'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.', - defaultValue: 2500, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.GENERAL, - sync: { - serverKey: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN, - paramType: SyncableParameterType.NUMBER - } - }, - { - key: SETTINGS_KEYS.SEND_ON_ENTER, - label: 'Send message on Enter', - help: 'Use Enter to send messages and Shift + Enter for new lines. When disabled, use Ctrl/Cmd + Enter.', - defaultValue: true, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.GENERAL, - sync: { serverKey: SETTINGS_KEYS.SEND_ON_ENTER, paramType: SyncableParameterType.BOOLEAN } - }, - { - key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT, - label: 'Copy text attachments as plain text', - help: 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.GENERAL, - sync: { - serverKey: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION, - label: 'Enable "Continue" button', - help: 'Enable "Continue" button for assistant messages, including reasoning models.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.GENERAL, - isExperimental: true, - sync: { - serverKey: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.PDF_AS_IMAGE, - label: 'Parse PDF as image', - help: 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.GENERAL, - sync: { serverKey: SETTINGS_KEYS.PDF_AS_IMAGE, paramType: SyncableParameterType.BOOLEAN } - }, - { - key: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION, - label: 'Ask for confirmation before changing conversation title', - help: 'Ask for confirmation before automatically changing conversation title when editing the first message.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.GENERAL, - sync: { - serverKey: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE, - label: 'Use first non-empty line for conversation title', - help: 'Use only the first non-empty line of the prompt to generate the conversation title.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.GENERAL, - sync: { - serverKey: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM, - label: 'Use LLM to generate conversation title', - help: 'Use the LLM to automatically generate conversation titles based on the first message exchange.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.GENERAL, - isExperimental: true - }, - { - key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT, - label: 'LLM title generation prompt', - help: 'Optional template for the title generation prompt. Use {{USER}} for the user message and {{ASSISTANT}} for the assistant message.', - defaultValue: TITLE_GENERATION.DEFAULT_PROMPT, - type: SettingsFieldType.TEXTAREA, - section: SETTINGS_SECTION_SLUGS.GENERAL - } - ] - }, - [SETTINGS_SECTION_SLUGS.DISPLAY]: { - title: SETTINGS_SECTION_TITLES.DISPLAY, - slug: SETTINGS_SECTION_SLUGS.DISPLAY, - icon: Monitor, - settings: [ - { - key: SETTINGS_KEYS.SHOW_MESSAGE_STATS, - label: 'Show message generation statistics', - help: 'Display generation statistics (tokens/second, token count, duration) below each assistant message.', - defaultValue: true, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.SHOW_MESSAGE_STATS, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS, - label: 'Show thought in progress', - help: 'Expand thought process by default when generating messages.', - defaultValue: true, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS, - label: 'Show tool call in progress', - help: 'Automatically expand tool call details while executing and keep them expanded after completion.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.KEEP_STATS_VISIBLE, - label: 'Keep stats visible after generation', - help: 'Keep processing statistics visible after generation finishes.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.KEEP_STATS_VISIBLE, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY, - label: 'Show microphone on empty input', - help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - isExperimental: true, - sync: { - serverKey: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN, - label: 'Render user content as Markdown', - help: 'Render user messages using markdown formatting in the chat.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS, - label: 'Use full height code blocks', - help: 'Always display code blocks at their full natural height, overriding any height limits.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL, - label: 'Disable automatic scroll', - help: 'Disable automatic scrolling while messages stream so you can control the viewport position manually.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.DISABLE_AUTO_SCROLL, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP, - label: 'Always show sidebar on desktop', - help: 'Always keep the sidebar visible on desktop instead of auto-hiding it.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES, - label: 'Show raw model names', - help: 'Display full raw model identifiers (e.g. "ggml-org/GLM-4.7-Flash-GGUF:Q8_0") instead of parsed names with badges.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS, - label: 'Always show agentic turns in conversation', - help: 'Always expand and display agentic loop turns in conversation messages.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS, - paramType: SyncableParameterType.BOOLEAN - } - } - ] - }, - [SETTINGS_SECTION_SLUGS.SAMPLING]: { - title: SETTINGS_SECTION_TITLES.SAMPLING, - slug: SETTINGS_SECTION_SLUGS.SAMPLING, - icon: Funnel, - settings: [ - { - key: SETTINGS_KEYS.TEMPERATURE, - label: 'Temperature', - help: 'Controls the randomness of the generated text by affecting the probability distribution of the output tokens. Higher = more random, lower = more focused.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.SAMPLING, - sync: { serverKey: SETTINGS_KEYS.TEMPERATURE, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.DYNATEMP_RANGE, - label: 'Dynamic temperature range', - help: 'Addon for the temperature sampler. The added value to the range of dynamic temperature, which adjusts probabilities by entropy of tokens.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.SAMPLING, - sync: { serverKey: SETTINGS_KEYS.DYNATEMP_RANGE, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.DYNATEMP_EXPONENT, - label: 'Dynamic temperature exponent', - help: 'Addon for the temperature sampler. Smoothes out the probability redistribution based on the most probable token.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.SAMPLING, - sync: { - serverKey: SETTINGS_KEYS.DYNATEMP_EXPONENT, - paramType: SyncableParameterType.NUMBER - } - }, - { - key: SETTINGS_KEYS.TOP_K, - label: 'Top K', - help: 'Keeps only k top tokens.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.SAMPLING, - sync: { serverKey: SETTINGS_KEYS.TOP_K, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.TOP_P, - label: 'Top P', - help: 'Limits tokens to those that together have a cumulative probability of at least p', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.SAMPLING, - sync: { serverKey: SETTINGS_KEYS.TOP_P, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.MIN_P, - label: 'Min P', - help: 'Limits tokens based on the minimum probability for a token to be considered, relative to the probability of the most likely token.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.SAMPLING, - sync: { serverKey: SETTINGS_KEYS.MIN_P, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.XTC_PROBABILITY, - label: 'XTC probability', - help: 'XTC sampler cuts out top tokens; this parameter controls the chance of cutting tokens at all. 0 disables XTC.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.SAMPLING, - sync: { serverKey: SETTINGS_KEYS.XTC_PROBABILITY, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.XTC_THRESHOLD, - label: 'XTC threshold', - help: 'XTC sampler cuts out top tokens; this parameter controls the token probability that is required to cut that token.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.SAMPLING, - sync: { serverKey: SETTINGS_KEYS.XTC_THRESHOLD, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.TYP_P, - label: 'Typical P', - help: 'Sorts and limits tokens based on the difference between log-probability and entropy.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.SAMPLING, - sync: { serverKey: SETTINGS_KEYS.TYP_P, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.MAX_TOKENS, - label: 'Max tokens', - help: 'The maximum number of token per output. Use -1 for infinite (no limit).', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.SAMPLING, - sync: { serverKey: SETTINGS_KEYS.MAX_TOKENS, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.SAMPLERS, - label: 'Samplers', - help: 'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature', - defaultValue: '', - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.SAMPLING, - sync: { serverKey: SETTINGS_KEYS.SAMPLERS, paramType: SyncableParameterType.STRING } - }, - { - key: SETTINGS_KEYS.BACKEND_SAMPLING, - label: 'Backend sampling', - help: 'Enable backend-based samplers. When enabled, supported samplers run on the accelerator backend for faster sampling.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.SAMPLING, - sync: { - serverKey: SETTINGS_KEYS.BACKEND_SAMPLING, - paramType: SyncableParameterType.BOOLEAN - } - } - ] - }, - [SETTINGS_SECTION_SLUGS.PENALTIES]: { - title: SETTINGS_SECTION_TITLES.PENALTIES, - slug: SETTINGS_SECTION_SLUGS.PENALTIES, - icon: AlertTriangle, - settings: [ - { - key: SETTINGS_KEYS.REPEAT_LAST_N, - label: 'Repeat last N', - help: 'Last n tokens to consider for penalizing repetition', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.PENALTIES, - sync: { serverKey: SETTINGS_KEYS.REPEAT_LAST_N, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.REPEAT_PENALTY, - label: 'Repeat penalty', - help: 'Controls the repetition of token sequences in the generated text', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.PENALTIES, - sync: { serverKey: SETTINGS_KEYS.REPEAT_PENALTY, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.PRESENCE_PENALTY, - label: 'Presence penalty', - help: 'Limits tokens based on whether they appear in the output or not.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.PENALTIES, - sync: { serverKey: SETTINGS_KEYS.PRESENCE_PENALTY, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.FREQUENCY_PENALTY, - label: 'Frequency penalty', - help: 'Limits tokens based on how often they appear in the output.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.PENALTIES, - sync: { - serverKey: SETTINGS_KEYS.FREQUENCY_PENALTY, - paramType: SyncableParameterType.NUMBER - } - }, - { - key: SETTINGS_KEYS.DRY_MULTIPLIER, - label: 'DRY multiplier', - help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.PENALTIES, - sync: { serverKey: SETTINGS_KEYS.DRY_MULTIPLIER, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.DRY_BASE, - label: 'DRY base', - help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.PENALTIES, - sync: { serverKey: SETTINGS_KEYS.DRY_BASE, paramType: SyncableParameterType.NUMBER } - }, - { - key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH, - label: 'DRY allowed length', - help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.PENALTIES, - sync: { - serverKey: SETTINGS_KEYS.DRY_ALLOWED_LENGTH, - paramType: SyncableParameterType.NUMBER - } - }, - { - key: SETTINGS_KEYS.DRY_PENALTY_LAST_N, - label: 'DRY penalty last N', - help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.', - defaultValue: undefined, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.PENALTIES, - sync: { - serverKey: SETTINGS_KEYS.DRY_PENALTY_LAST_N, - paramType: SyncableParameterType.NUMBER - } - } - ] - }, - [SETTINGS_SECTION_SLUGS.AGENTIC]: { - title: SETTINGS_SECTION_TITLES.AGENTIC, - slug: SETTINGS_SECTION_SLUGS.AGENTIC, - icon: ListRestart, - settings: [ - { - key: SETTINGS_KEYS.AGENTIC_MAX_TURNS, - label: 'Agentic turns', - help: 'Maximum number of tool execution cycles before stopping (prevents infinite loops).', - defaultValue: 10, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.AGENTIC, - isPositiveInteger: true, - sync: { - serverKey: SETTINGS_KEYS.AGENTIC_MAX_TURNS, - paramType: SyncableParameterType.NUMBER - } - }, - { - key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES, - label: 'Max lines per tool preview', - help: 'Number of lines shown in tool output previews (last N lines). Only these previews and the final LLM response persist after the agentic loop completes.', - defaultValue: 25, - type: SettingsFieldType.INPUT, - section: SETTINGS_SECTION_SLUGS.AGENTIC, - isPositiveInteger: true, - sync: { - serverKey: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES, - paramType: SyncableParameterType.NUMBER - } - } - ] - }, - [SETTINGS_SECTION_SLUGS.DEVELOPER]: { - title: SETTINGS_SECTION_TITLES.DEVELOPER, - slug: SETTINGS_SECTION_SLUGS.DEVELOPER, - icon: Code, - settings: [ - { - key: SETTINGS_KEYS.PRE_ENCODE_CONVERSATION, - label: 'Pre-fill KV cache after response', - help: 'After each response, re-submit the conversation to pre-fill the server KV cache. Makes the next turn faster since the prompt is already encoded while you read the response.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DEVELOPER - }, - { - key: SETTINGS_KEYS.DISABLE_REASONING_PARSING, - label: 'Disable reasoning content parsing', - help: 'Send reasoning_format=none so the server returns thinking tokens inline instead of extracting them into a separate field.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DEVELOPER - }, - { - key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT, - label: 'Exclude reasoning from context', - help: 'Strip thinking from previous messages before sending. When off, thinking is sent back via the reasoning_content field so the model sees its own chain-of-thought across turns.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DEVELOPER, - sync: { - serverKey: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH, - label: 'Enable raw output toggle', - help: 'Show toggle button to display messages as plain text instead of Markdown-formatted content', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DEVELOPER, - sync: { - serverKey: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH, - paramType: SyncableParameterType.BOOLEAN - } - }, - { - key: SETTINGS_KEYS.CUSTOM, - label: 'Custom JSON', - help: 'Custom JSON parameters to send to the API. Must be valid JSON format.', - defaultValue: '', - type: SettingsFieldType.TEXTAREA, - section: SETTINGS_SECTION_SLUGS.DEVELOPER - } - ] - } -} as const; - -const NON_UI_SETTINGS: SettingsEntry[] = [ - { - key: SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, - label: 'Show system message', - help: 'Display the system message at the top of each conversation.', - defaultValue: true, - type: SettingsFieldType.CHECKBOX, - sync: { serverKey: SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, paramType: SyncableParameterType.BOOLEAN } - }, - { - key: SETTINGS_KEYS.MCP_SERVERS, - label: 'MCP servers', - help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.', - defaultValue: '[]', - type: SettingsFieldType.INPUT, - sync: { serverKey: SETTINGS_KEYS.MCP_SERVERS, paramType: SyncableParameterType.STRING } - } - // { - // key: SETTINGS_KEYS.PY_INTERPRETER_ENABLED, - // label: 'Python interpreter enabled', - // help: 'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.', - // defaultValue: false, - // type: SettingsFieldType.CHECKBOX, - // isExperimental: true, - // sync: { serverKey: SETTINGS_KEYS.PY_INTERPRETER_ENABLED, paramType: SyncableParameterType.BOOLEAN } - // } -]; - -function getAllSettings(): SettingsEntry[] { - const result: SettingsEntry[] = []; - for (const section of Object.values(SETTINGS_REGISTRY)) { - result.push(...section.settings); - } - result.push(...NON_UI_SETTINGS); - return result; -} - -/** Flat config object stored in localStorage. */ -export const SETTING_CONFIG_DEFAULT: Record = Object.fromEntries( - getAllSettings().map((s) => [s.key, s.defaultValue]) -) as Record; - -/** Help text for every setting (including non-UI). */ -export const SETTING_CONFIG_INFO: Record = Object.fromEntries( - getAllSettings().map((s) => [s.key, s.help]) -) as Record; - -/** Theme select options. */ -export const SETTINGS_COLOR_MODES_CONFIG = COLOR_MODE_OPTIONS; - -export type { SettingsSectionTitle } from '$lib/types'; -export type { SettingsSection } from '$lib/types'; - -/** Sidebar sections + field configs (as consumed by UI). */ -export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [ - ...Object.values(SETTINGS_REGISTRY).map((section) => ({ - title: section.title, - slug: section.slug, - icon: section.icon, - fields: section.settings.map((s) => ({ - key: s.key, - label: s.label, - type: s.type, - isExperimental: s.isExperimental, - help: s.help, - options: s.options - })) - })), - ...STANDALONE_SECTIONS -]; - -/** INPUT-type settings whose value is a number. */ -export const NUMERIC_FIELDS = getAllSettings() - .filter((s) => s.type === SettingsFieldType.INPUT && typeof s.defaultValue !== 'string') - .map((s) => s.key) as readonly string[]; - -/** Numeric fields clamped to ≥ 1 and rounded. */ -export const POSITIVE_INTEGER_FIELDS = getAllSettings() - .filter((s) => s.isPositiveInteger) - .map((s) => s.key) as readonly string[]; - -/** Derived for the parameter sync service. */ -export const SYNCABLE_PARAMETERS: SyncableParameter[] = getAllSettings() - .filter((s) => s.sync !== undefined) - .map((s) => ({ - key: s.key, - serverKey: s.sync!.serverKey, - type: s.sync!.paramType, - canSync: true - })); - -export const SETTINGS_FALLBACK_EXIT_ROUTE = ROUTES.START; - -export { SETTINGS_KEYS } from './settings-keys'; diff --git a/tools/server/webui/src/lib/constants/supported-file-types.ts b/tools/server/webui/src/lib/constants/supported-file-types.ts deleted file mode 100644 index 0d955ad14..000000000 --- a/tools/server/webui/src/lib/constants/supported-file-types.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Comprehensive dictionary of all supported file types in webui - * Organized by category with TypeScript enums for better type safety - */ - -import { - FileExtensionAudio, - FileExtensionImage, - FileExtensionPdf, - FileExtensionText, - FileTypeAudio, - FileTypeImage, - FileTypePdf, - FileTypeText, - MimeTypeAudio, - MimeTypeImage, - MimeTypeApplication, - MimeTypeText -} from '$lib/enums'; - -// File type configuration using enums -export const AUDIO_FILE_TYPES = { - [FileTypeAudio.MP3]: { - extensions: [FileExtensionAudio.MP3], - mimeTypes: [MimeTypeAudio.MP3_MPEG, MimeTypeAudio.MP3] - }, - [FileTypeAudio.WAV]: { - extensions: [FileExtensionAudio.WAV], - mimeTypes: [MimeTypeAudio.WAV] - } -} as const; - -export const IMAGE_FILE_TYPES = { - [FileTypeImage.JPEG]: { - extensions: [FileExtensionImage.JPG, FileExtensionImage.JPEG], - mimeTypes: [MimeTypeImage.JPEG] - }, - [FileTypeImage.PNG]: { - extensions: [FileExtensionImage.PNG], - mimeTypes: [MimeTypeImage.PNG] - }, - [FileTypeImage.GIF]: { - extensions: [FileExtensionImage.GIF], - mimeTypes: [MimeTypeImage.GIF] - }, - [FileTypeImage.WEBP]: { - extensions: [FileExtensionImage.WEBP], - mimeTypes: [MimeTypeImage.WEBP] - }, - [FileTypeImage.SVG]: { - extensions: [FileExtensionImage.SVG], - mimeTypes: [MimeTypeImage.SVG] - } -} as const; - -export const PDF_FILE_TYPES = { - [FileTypePdf.PDF]: { - extensions: [FileExtensionPdf.PDF], - mimeTypes: [MimeTypeApplication.PDF] - } -} as const; - -export const TEXT_FILE_TYPES = { - [FileTypeText.PLAIN_TEXT]: { - extensions: [FileExtensionText.TXT], - mimeTypes: [MimeTypeText.PLAIN] - }, - [FileTypeText.MARKDOWN]: { - extensions: [FileExtensionText.MD], - mimeTypes: [MimeTypeText.MARKDOWN] - }, - [FileTypeText.ASCIIDOC]: { - extensions: [FileExtensionText.ADOC], - mimeTypes: [MimeTypeText.ASCIIDOC] - }, - [FileTypeText.JAVASCRIPT]: { - extensions: [FileExtensionText.JS], - mimeTypes: [MimeTypeText.JAVASCRIPT, MimeTypeText.JAVASCRIPT_APP] - }, - [FileTypeText.TYPESCRIPT]: { - extensions: [FileExtensionText.TS], - mimeTypes: [MimeTypeText.TYPESCRIPT] - }, - [FileTypeText.JSX]: { - extensions: [FileExtensionText.JSX], - mimeTypes: [MimeTypeText.JSX] - }, - [FileTypeText.TSX]: { - extensions: [FileExtensionText.TSX], - mimeTypes: [MimeTypeText.TSX] - }, - [FileTypeText.CSS]: { - extensions: [FileExtensionText.CSS], - mimeTypes: [MimeTypeText.CSS] - }, - [FileTypeText.HTML]: { - extensions: [FileExtensionText.HTML, FileExtensionText.HTM], - mimeTypes: [MimeTypeText.HTML] - }, - [FileTypeText.JSON]: { - extensions: [FileExtensionText.JSON], - mimeTypes: [MimeTypeText.JSON] - }, - [FileTypeText.XML]: { - extensions: [FileExtensionText.XML], - mimeTypes: [MimeTypeText.XML_TEXT, MimeTypeText.XML_APP] - }, - [FileTypeText.YAML]: { - extensions: [FileExtensionText.YAML, FileExtensionText.YML], - mimeTypes: [MimeTypeText.YAML_TEXT, MimeTypeText.YAML_APP] - }, - [FileTypeText.CSV]: { - extensions: [FileExtensionText.CSV], - mimeTypes: [MimeTypeText.CSV] - }, - [FileTypeText.LOG]: { - extensions: [FileExtensionText.LOG], - mimeTypes: [MimeTypeText.PLAIN] - }, - [FileTypeText.PYTHON]: { - extensions: [FileExtensionText.PY], - mimeTypes: [MimeTypeText.PYTHON] - }, - [FileTypeText.JAVA]: { - extensions: [FileExtensionText.JAVA], - mimeTypes: [MimeTypeText.JAVA] - }, - [FileTypeText.CPP]: { - extensions: [ - FileExtensionText.CPP, - FileExtensionText.C, - FileExtensionText.H, - FileExtensionText.HPP - ], - mimeTypes: [MimeTypeText.CPP_SRC, MimeTypeText.CPP_HDR, MimeTypeText.C_SRC, MimeTypeText.C_HDR] - }, - [FileTypeText.PHP]: { - extensions: [FileExtensionText.PHP], - mimeTypes: [MimeTypeText.PHP] - }, - [FileTypeText.RUBY]: { - extensions: [FileExtensionText.RB], - mimeTypes: [MimeTypeText.RUBY] - }, - [FileTypeText.GO]: { - extensions: [FileExtensionText.GO], - mimeTypes: [MimeTypeText.GO] - }, - [FileTypeText.RUST]: { - extensions: [FileExtensionText.RS], - mimeTypes: [MimeTypeText.RUST] - }, - [FileTypeText.SHELL]: { - extensions: [FileExtensionText.SH, FileExtensionText.BAT], - mimeTypes: [MimeTypeText.SHELL, MimeTypeText.BAT] - }, - [FileTypeText.SQL]: { - extensions: [FileExtensionText.SQL], - mimeTypes: [MimeTypeText.SQL] - }, - [FileTypeText.R]: { - extensions: [FileExtensionText.R], - mimeTypes: [MimeTypeText.R] - }, - [FileTypeText.SCALA]: { - extensions: [FileExtensionText.SCALA], - mimeTypes: [MimeTypeText.SCALA] - }, - [FileTypeText.KOTLIN]: { - extensions: [FileExtensionText.KT], - mimeTypes: [MimeTypeText.KOTLIN] - }, - [FileTypeText.SWIFT]: { - extensions: [FileExtensionText.SWIFT], - mimeTypes: [MimeTypeText.SWIFT] - }, - [FileTypeText.DART]: { - extensions: [FileExtensionText.DART], - mimeTypes: [MimeTypeText.DART] - }, - [FileTypeText.VUE]: { - extensions: [FileExtensionText.VUE], - mimeTypes: [MimeTypeText.VUE] - }, - [FileTypeText.SVELTE]: { - extensions: [FileExtensionText.SVELTE], - mimeTypes: [MimeTypeText.SVELTE] - }, - [FileTypeText.LATEX]: { - extensions: [FileExtensionText.TEX], - mimeTypes: [MimeTypeText.LATEX, MimeTypeText.TEX, MimeTypeText.TEX_APP] - }, - [FileTypeText.BIBTEX]: { - extensions: [FileExtensionText.BIB], - mimeTypes: [MimeTypeText.BIBTEX] - }, - [FileTypeText.CUDA]: { - extensions: [FileExtensionText.CU, FileExtensionText.CUH], - mimeTypes: [MimeTypeText.CUDA] - }, - [FileTypeText.VULKAN]: { - extensions: [FileExtensionText.COMP], - mimeTypes: [MimeTypeText.PLAIN] - }, - [FileTypeText.HASKELL]: { - extensions: [FileExtensionText.HS], - mimeTypes: [MimeTypeText.HASKELL] - }, - [FileTypeText.CSHARP]: { - extensions: [FileExtensionText.CS], - mimeTypes: [MimeTypeText.CSHARP] - }, - [FileTypeText.PROPERTIES]: { - extensions: [FileExtensionText.PROPERTIES], - mimeTypes: [MimeTypeText.PROPERTIES] - } -} as const; diff --git a/tools/server/webui/src/lib/constants/table-html-restorer.ts b/tools/server/webui/src/lib/constants/table-html-restorer.ts deleted file mode 100644 index e5d5b1201..000000000 --- a/tools/server/webui/src/lib/constants/table-html-restorer.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Matches
        ,
        ,
        tags (case-insensitive). - * Used to detect line breaks in table cell text content. - */ -export const BR_PATTERN = //gi; - -/** - * Matches a complete
          ...
        block. - * Captures the inner content (group 1) for further
      • extraction. - * Case-insensitive, allows multiline content. - */ -export const LIST_PATTERN = /^
          ([\s\S]*)<\/ul>$/i; - -/** - * Matches individual
        • ...
        • elements within a list. - * Captures the inner content (group 1) of each list item. - * Non-greedy to handle multiple consecutive items. - * Case-insensitive, allows multiline content. - */ -export const LI_PATTERN = /
        • ([\s\S]*?)<\/li>/gi; diff --git a/tools/server/webui/src/lib/constants/title-generation.ts b/tools/server/webui/src/lib/constants/title-generation.ts deleted file mode 100644 index 48ca2217a..000000000 --- a/tools/server/webui/src/lib/constants/title-generation.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* Title generation constants */ -export const TITLE_GENERATION = { - MIN_LENGTH: 3, - FALLBACK: 'New Chat', - DEFAULT_PROMPT: - 'Based on the following interaction, generate a short, concise title (maximum 6-8 words) that captures the main topic. Return ONLY the title text, nothing else. Do not use quotes.\n\nUser: {{USER}}\n\nAssistant: {{ASSISTANT}}\n\nTitle:', - PREFIX_PATTERN: /^(Title:|Subject:|Topic:)\s*/i, - QUOTE_PATTERN: /^["]|["]$/g -} as const; diff --git a/tools/server/webui/src/lib/constants/tools.ts b/tools/server/webui/src/lib/constants/tools.ts deleted file mode 100644 index 22b22309c..000000000 --- a/tools/server/webui/src/lib/constants/tools.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ToolSource } from '$lib/enums/tools'; - -export const TOOL_GROUP_LABELS = { - [ToolSource.BUILTIN]: 'Built-in', - [ToolSource.CUSTOM]: 'JSON Schema' -} as const; - -export const TOOL_SERVER_LABELS = { - [ToolSource.BUILTIN]: 'Built-in Tools', - [ToolSource.CUSTOM]: 'Custom Tools' -} as const; diff --git a/tools/server/webui/src/lib/constants/tooltip-config.ts b/tools/server/webui/src/lib/constants/tooltip-config.ts deleted file mode 100644 index ad76ab352..000000000 --- a/tools/server/webui/src/lib/constants/tooltip-config.ts +++ /dev/null @@ -1 +0,0 @@ -export const TOOLTIP_DELAY_DURATION = 500; diff --git a/tools/server/webui/src/lib/constants/ui.ts b/tools/server/webui/src/lib/constants/ui.ts deleted file mode 100644 index f6e7f7d8a..000000000 --- a/tools/server/webui/src/lib/constants/ui.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Settings, Search, SquarePen } from '@lucide/svelte'; -import McpLogo from '$lib/components/app/mcp/McpLogo.svelte'; -import type { Component } from 'svelte'; -import { ROUTES } from './routes'; - -export const FORK_TREE_DEPTH_PADDING = 8; -export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message'; -export const APP_NAME = import.meta.env.VITE_PUBLIC_APP_NAME || 'llama-ui'; - -export const ICON_STRIP_TRANSITION_DURATION = 150; -export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50; - -export interface DesktopIconStripItem { - icon: Component; - tooltip: string; - route?: string; - activeRouteId?: string; - activeRoutePrefix?: string; - keys?: string[]; -} - -export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [ - { icon: SquarePen, tooltip: 'New chat', route: ROUTES.NEW_CHAT, keys: ['shift', 'cmd', 'o'] }, - { icon: Search, tooltip: 'Search', keys: ['cmd', 'k'] }, - { - icon: McpLogo, - tooltip: 'MCP Servers', - route: ROUTES.MCP_SERVERS, - activeRouteId: '/mcp-servers' - }, - { - icon: Settings, - tooltip: 'Settings', - route: ROUTES.SETTINGS, - activeRoutePrefix: '/settings' - } -]; diff --git a/tools/server/webui/src/lib/constants/uri-template.ts b/tools/server/webui/src/lib/constants/uri-template.ts deleted file mode 100644 index dc834aca2..000000000 --- a/tools/server/webui/src/lib/constants/uri-template.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * URI Template constants for RFC 6570 template processing. - */ - -/** URI scheme separator */ -export const URI_SCHEME_SEPARATOR = '://'; - -/** Regex to match template expressions like {var}, {+var}, {#var}, {/var} */ -export const TEMPLATE_EXPRESSION_REGEX = /\{([+#./;?&]?)([^}]+)\}/g; - -/** RFC 6570 URI template operators */ -export const URI_TEMPLATE_OPERATORS = { - /** Simple string expansion (default) */ - SIMPLE: '', - /** Reserved expansion */ - RESERVED: '+', - /** Fragment expansion */ - FRAGMENT: '#', - /** Path segment expansion */ - PATH_SEGMENT: '/', - /** Label expansion */ - LABEL: '.', - /** Path-style parameters */ - PATH_PARAM: ';', - /** Form-style query */ - FORM_QUERY: '?', - /** Form-style query continuation */ - FORM_CONTINUATION: '&' -} as const; - -/** URI template separators used in expansion */ -export const URI_TEMPLATE_SEPARATORS = { - /** Comma separator for list expansion */ - COMMA: ',', - /** Slash separator for path segments */ - SLASH: '/', - /** Period separator for label expansion */ - PERIOD: '.', - /** Semicolon separator for path parameters */ - SEMICOLON: ';', - /** Question mark prefix for query string */ - QUERY_PREFIX: '?', - /** Ampersand prefix for query continuation */ - QUERY_CONTINUATION: '&' -} as const; - -/** Maximum number of leading slashes to strip during URI normalization */ -export const MAX_LEADING_SLASHES_TO_STRIP = 3; - -/** Regex to strip explode modifier (*) from variable names */ -export const VARIABLE_EXPLODE_MODIFIER_REGEX = /[*]$/; - -/** Regex to strip prefix modifier (:N) from variable names */ -export const VARIABLE_PREFIX_MODIFIER_REGEX = /:[\d]+$/; - -/** Regex to strip one or more leading slashes */ -export const LEADING_SLASHES_REGEX = /^\/+/; diff --git a/tools/server/webui/src/lib/constants/url.ts b/tools/server/webui/src/lib/constants/url.ts deleted file mode 100644 index 0afb9decc..000000000 --- a/tools/server/webui/src/lib/constants/url.ts +++ /dev/null @@ -1,186 +0,0 @@ -const STD = ['com', 'net', 'org', 'gov', 'edu'] as const; - -const STD_MIL = [...STD, 'mil'] as const; - -const ccTLD_PREFIXES: Record = { - // --- Standard 5 only --- - ar: STD, - bd: STD, - bg: STD, - cn: STD_MIL, - eg: STD, - gr: STD, - hk: STD, - hr: STD, - lk: STD, - mx: STD_MIL, - my: STD_MIL, - ng: STD, - ph: STD, - pk: STD, - pl: STD, - ro: STD, - ru: STD, - sa: STD, - si: STD, - tr: STD, - tw: STD, - ua: STD, - ve: STD, - - au: [...STD_MIL, 'id', 'asn', 'csiro'], - br: [ - ...STD_MIL, - 'art', - 'eco', - 'eng', - 'inf', - 'med', - 'psi', - 'tmp', - 'etc', - 'adm', - 'adv', - 'arq', - 'bio', - 'bmd', - 'cim', - 'cng', - 'cnt', - 'coop', - 'ecn', - 'esp', - 'far', - 'fm', - 'fnd', - 'fot', - 'fst', - 'g12', - 'ggf', - 'imb', - 'ind', - 'jor', - 'jus', - 'leg', - 'lel', - 'mat', - 'mp', - 'mus', - 'not', - 'ntr', - 'odo', - 'ppg', - 'pro', - 'psc', - 'qsl', - 'rec', - 'slg', - 'srv', - 'trd', - 'tur', - 'tv', - 'vet', - 'vlog', - 'wiki', - 'zlg' - ], - id: [...STD_MIL, 'co', 'go', 'or', 'web', 'sch'], - in: [...STD_MIL, 'co', 'gen', 'ind', 'firm', 'ernet', 'nic'], - kr: [...STD_MIL, 'co', 'go', 'or', 'ac', 're'], - nz: [ - ...STD_MIL, - 'co', - 'gen', - 'geek', - 'kiwi', - 'maori', - 'school', - 'govt', - 'health', - 'iwi', - 'parliament' - ], - sg: [...STD, 'per'], - th: ['co', 'go', 'or', 'in', 'ac', 'mi', 'net'], - - ae: ['co', 'net', 'org', 'gov', 'ac', 'sch'], - hu: ['co', 'net', 'org', 'gov', 'edu'], - il: ['co', 'net', 'org', 'gov', 'ac', 'muni'], - jp: ['ac', 'ad', 'co', 'ed', 'go', 'gr', 'lg', 'ne', 'or'], - ke: ['co', 'or', 'ne', 'go', 'ac', 'sc'], - rs: ['co', 'net', 'org', 'gov', 'edu'], - uk: ['co', 'org', 'net', 'ac', 'gov', 'mil', 'nhs', 'police', 'mod', 'ltd', 'plc', 'me', 'sch'], - za: ['co', 'org', 'net', 'web', 'law', 'mil'] -}; - -const WILDCARD_BASES: Record = { - br: ['nom', 'blog'], - jp: [ - 'kobe', - 'kyoto', - 'nagoya', - 'osaka', - 'sapporo', - 'sendai', - 'tokyo', - 'yokohama', - 'aichi', - 'akita', - 'aomori', - 'chiba', - 'ehime', - 'fukui', - 'fukuoka', - 'fukushima', - 'gifu', - 'gunma', - 'hiroshima', - 'hokkaido', - 'hyogo', - 'ibaraki', - 'ishikawa', - 'iwate', - 'kagawa', - 'kagoshima', - 'kanagawa', - 'kochi', - 'kumamoto', - 'mie', - 'miyagi', - 'miyazaki', - 'nagano', - 'nara', - 'niigata', - 'oita', - 'okayama', - 'okinawa', - 'saga', - 'saitama', - 'shiga', - 'shimane', - 'shizuoka', - 'tochigi', - 'tokushima', - 'tottori', - 'toyama', - 'wakayama', - 'yamagata', - 'yamaguchi', - 'yamanashi' - ] -}; - -function buildSuffixSet(suffixes: Record): Set { - const set = new Set(); - - for (const [tld, parts] of Object.entries(suffixes)) { - for (const part of parts) { - set.add(`${part}.${tld}`); - } - } - - return set; -} - -export const TWO_PART_PUBLIC_SUFFIXES = buildSuffixSet(ccTLD_PREFIXES); -export const WILDCARD_PUBLIC_SUFFIXES = buildSuffixSet(WILDCARD_BASES); diff --git a/tools/server/webui/src/lib/constants/viewport.ts b/tools/server/webui/src/lib/constants/viewport.ts deleted file mode 100644 index 26e202cfe..000000000 --- a/tools/server/webui/src/lib/constants/viewport.ts +++ /dev/null @@ -1 +0,0 @@ -export const DEFAULT_MOBILE_BREAKPOINT = 768; diff --git a/tools/server/webui/src/lib/contexts/chat-actions.context.ts b/tools/server/webui/src/lib/contexts/chat-actions.context.ts deleted file mode 100644 index e9050fa27..000000000 --- a/tools/server/webui/src/lib/contexts/chat-actions.context.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { getContext, setContext } from 'svelte'; -import { CONTEXT_KEY_CHAT_ACTIONS } from '$lib/constants'; - -export interface ChatActionsContext { - copy: (message: DatabaseMessage) => void; - delete: (message: DatabaseMessage) => void; - navigateToSibling: (siblingId: string) => void; - editWithBranching: ( - message: DatabaseMessage, - newContent: string, - newExtras?: DatabaseMessageExtra[] - ) => void; - editWithReplacement: ( - message: DatabaseMessage, - newContent: string, - shouldBranch: boolean - ) => void; - editUserMessagePreserveResponses: ( - message: DatabaseMessage, - newContent: string, - newExtras?: DatabaseMessageExtra[] - ) => void; - regenerateWithBranching: (message: DatabaseMessage, modelOverride?: string) => void; - continueAssistantMessage: (message: DatabaseMessage) => void; - forkConversation: ( - message: DatabaseMessage, - options: { name: string; includeAttachments: boolean } - ) => void; -} - -const CHAT_ACTIONS_KEY = Symbol.for(CONTEXT_KEY_CHAT_ACTIONS); - -export function setChatActionsContext(ctx: ChatActionsContext): ChatActionsContext { - return setContext(CHAT_ACTIONS_KEY, ctx); -} - -export function getChatActionsContext(): ChatActionsContext { - return getContext(CHAT_ACTIONS_KEY); -} diff --git a/tools/server/webui/src/lib/contexts/chat-settings-config.context.ts b/tools/server/webui/src/lib/contexts/chat-settings-config.context.ts deleted file mode 100644 index 35941e09b..000000000 --- a/tools/server/webui/src/lib/contexts/chat-settings-config.context.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { getContext, setContext } from 'svelte'; -import { CONTEXT_KEY_CHAT_SETTINGS_CONFIG } from '$lib/constants'; - -export interface ChatSettingsConfigContext { - readonly localConfig: SettingsConfigType; - handleConfigChange: (key: string, value: string | boolean) => void; - handleThemeChange: (theme: string) => void; -} - -const CHAT_SETTINGS_CONFIG_KEY = Symbol.for(CONTEXT_KEY_CHAT_SETTINGS_CONFIG); - -export function setChatSettingsConfigContext( - ctx: ChatSettingsConfigContext -): ChatSettingsConfigContext { - return setContext(CHAT_SETTINGS_CONFIG_KEY, ctx); -} - -export function getChatSettingsConfigContext(): ChatSettingsConfigContext { - return getContext(CHAT_SETTINGS_CONFIG_KEY); -} diff --git a/tools/server/webui/src/lib/contexts/index.ts b/tools/server/webui/src/lib/contexts/index.ts deleted file mode 100644 index 01cd1d4b7..000000000 --- a/tools/server/webui/src/lib/contexts/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -export { - getMessageEditContext, - setMessageEditContext, - type MessageEditContext, - type MessageEditState, - type MessageEditActions -} from './message-edit.context'; - -export { - getChatActionsContext, - setChatActionsContext, - type ChatActionsContext -} from './chat-actions.context'; - -export { - getChatSettingsConfigContext, - setChatSettingsConfigContext, - type ChatSettingsConfigContext -} from './chat-settings-config.context'; - -export { - getProcessingInfoContext, - setProcessingInfoContext, - type ProcessingInfoContext -} from './processing-info.context'; diff --git a/tools/server/webui/src/lib/contexts/message-edit.context.ts b/tools/server/webui/src/lib/contexts/message-edit.context.ts deleted file mode 100644 index b6231f940..000000000 --- a/tools/server/webui/src/lib/contexts/message-edit.context.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { getContext, setContext } from 'svelte'; -import { CONTEXT_KEY_MESSAGE_EDIT } from '$lib/constants'; -import { MessageRole } from '$lib/enums'; - -export interface MessageEditState { - readonly isEditing: boolean; - readonly editedContent: string; - readonly editedExtras: DatabaseMessageExtra[]; - readonly editedUploadedFiles: ChatUploadedFile[]; - readonly originalContent: string; - readonly originalExtras: DatabaseMessageExtra[]; - readonly showSaveOnlyOption: boolean; - readonly showBranchAfterEditOption: boolean; - readonly shouldBranchAfterEdit: boolean; - readonly messageRole: MessageRole; - readonly rawEditContent?: string; -} - -export interface MessageEditActions { - setContent: (content: string) => void; - setExtras: (extras: DatabaseMessageExtra[]) => void; - setUploadedFiles: (files: ChatUploadedFile[]) => void; - save: () => void; - saveOnly: () => void; - cancel: () => void; - startEdit: () => void; -} - -export interface AssistantEditActions { - setShouldBranchAfterEdit: (value: boolean) => void; -} - -export type MessageEditContext = MessageEditState & - MessageEditActions & - Partial; - -const MESSAGE_EDIT_KEY = Symbol.for(CONTEXT_KEY_MESSAGE_EDIT); - -/** - * Sets the message edit context. Call this in the parent component (ChatMessage.svelte). - */ -export function setMessageEditContext(ctx: MessageEditContext): MessageEditContext { - return setContext(MESSAGE_EDIT_KEY, ctx); -} - -/** - * Gets the message edit context. Call this in child components. - */ -export function getMessageEditContext(): MessageEditContext { - return getContext(MESSAGE_EDIT_KEY); -} diff --git a/tools/server/webui/src/lib/contexts/processing-info.context.ts b/tools/server/webui/src/lib/contexts/processing-info.context.ts deleted file mode 100644 index 0cf43336f..000000000 --- a/tools/server/webui/src/lib/contexts/processing-info.context.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { getContext, setContext } from 'svelte'; -import { CONTEXT_KEY_PROCESSING_INFO } from '$lib/constants'; - -export interface ProcessingInfoContext { - readonly showProcessingInfo: boolean; -} - -const PROCESSING_INFO_KEY = Symbol.for(CONTEXT_KEY_PROCESSING_INFO); - -export function setProcessingInfoContext(ctx: ProcessingInfoContext): ProcessingInfoContext { - return setContext(PROCESSING_INFO_KEY, ctx); -} - -export function getProcessingInfoContext(): ProcessingInfoContext { - return getContext(PROCESSING_INFO_KEY); -} diff --git a/tools/server/webui/src/lib/enums/agentic.ts b/tools/server/webui/src/lib/enums/agentic.ts deleted file mode 100644 index b96d244cd..000000000 --- a/tools/server/webui/src/lib/enums/agentic.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * OpenAI-compatible tool call type. - */ -export enum ToolCallType { - FUNCTION = 'function' -} - -/** - * Types of sections in agentic content display. - */ -export enum AgenticSectionType { - TEXT = 'text', - TOOL_CALL = 'tool_call', - TOOL_CALL_PENDING = 'tool_call_pending', - TOOL_CALL_STREAMING = 'tool_call_streaming', - REASONING = 'reasoning', - REASONING_PENDING = 'reasoning_pending' -} diff --git a/tools/server/webui/src/lib/enums/attachment.ts b/tools/server/webui/src/lib/enums/attachment.ts deleted file mode 100644 index 49baf6bae..000000000 --- a/tools/server/webui/src/lib/enums/attachment.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Attachment type enum for database message extras - */ -export enum AttachmentType { - AUDIO = 'AUDIO', - IMAGE = 'IMAGE', - MCP_PROMPT = 'MCP_PROMPT', - MCP_RESOURCE = 'MCP_RESOURCE', - PDF = 'PDF', - TEXT = 'TEXT', - LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility -} - -/** - * Unique identifiers for attachment menu items in the chat form action dropdowns. - * Used to select which file upload or attachment action is triggered. - */ -export enum AttachmentMenuItemId { - IMAGES = 'images', - AUDIO = 'audio', - TEXT = 'text', - PDF = 'pdf', - SYSTEM_MESSAGE = 'system-message', - MCP_PROMPT = 'mcp-prompt', - MCP_RESOURCES = 'mcp-resources' -} - -/** - * Defines when an attachment menu item should be enabled. - */ -export enum AttachmentItemEnabledWhen { - ALWAYS = 'always', - HAS_VISION_MODALITY = 'hasVisionModality', - HAS_AUDIO_MODALITY = 'hasAudioModality' -} - -/** - * Defines the callback action triggered when an attachment menu item is clicked. - */ -export enum AttachmentAction { - FILE_UPLOAD = 'onFileUpload', - SYSTEM_PROMPT_CLICK = 'onSystemPromptClick', - MCP_PROMPT_CLICK = 'onMcpPromptClick', - MCP_RESOURCES_CLICK = 'onMcpResourcesClick' -} - -/** - * Visibility conditions for attachment menu items. - */ -export enum AttachmentItemVisibleWhen { - HAS_MCP_PROMPTS_SUPPORT = 'hasMcpPromptsSupport', - HAS_MCP_RESOURCES_SUPPORT = 'hasMcpResourcesSupport' -} diff --git a/tools/server/webui/src/lib/enums/chat.ts b/tools/server/webui/src/lib/enums/chat.ts deleted file mode 100644 index ff67436ab..000000000 --- a/tools/server/webui/src/lib/enums/chat.ts +++ /dev/null @@ -1,64 +0,0 @@ -export enum ChatMessageStatsView { - GENERATION = 'generation', - READING = 'reading', - TOOLS = 'tools', - SUMMARY = 'summary' -} - -/** - * Reasoning format options for API requests. - */ -export enum ReasoningFormat { - NONE = 'none', - AUTO = 'auto' -} - -/** - * Message roles for chat messages. - */ -export enum MessageRole { - USER = 'user', - ASSISTANT = 'assistant', - SYSTEM = 'system', - TOOL = 'tool' -} - -/** - * Message types for different content kinds. - */ -export enum MessageType { - ROOT = 'root', - TEXT = 'text', - THINK = 'think', - SYSTEM = 'system' -} - -/** - * Content part types for API chat message content. - */ -export enum ContentPartType { - TEXT = 'text', - IMAGE_URL = 'image_url', - INPUT_AUDIO = 'input_audio' -} - -/** - * Error dialog types for displaying server/timeout errors. - */ -export enum ErrorDialogType { - TIMEOUT = 'timeout', - SERVER = 'server' -} - -export enum ConversationSelectionMode { - EXPORT = 'export', - IMPORT = 'import' -} - -/** - * PDF view mode options for previewing PDF attachments. - */ -export enum PdfViewMode { - TEXT = 'text', - PAGES = 'pages' -} diff --git a/tools/server/webui/src/lib/enums/files.ts b/tools/server/webui/src/lib/enums/files.ts deleted file mode 100644 index 29e0a501a..000000000 --- a/tools/server/webui/src/lib/enums/files.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Comprehensive dictionary of all supported file types in webui - * Organized by category with TypeScript enums for better type safety - */ - -// File type category enum -export enum FileTypeCategory { - IMAGE = 'image', - AUDIO = 'audio', - PDF = 'pdf', - TEXT = 'text' -} - -/** - * Special file types for internal use (not MIME types) - */ -export enum SpecialFileType { - MCP_PROMPT = 'mcp-prompt' -} - -// Specific file type enums for each category -export enum FileTypeImage { - JPEG = 'jpeg', - PNG = 'png', - GIF = 'gif', - WEBP = 'webp', - SVG = 'svg' -} - -export enum FileTypeAudio { - MP3 = 'mp3', - WAV = 'wav', - WEBM = 'webm' -} - -export enum FileTypePdf { - PDF = 'pdf' -} - -export enum FileTypeText { - PLAIN_TEXT = 'plainText', - MARKDOWN = 'md', - ASCIIDOC = 'asciidoc', - JAVASCRIPT = 'js', - TYPESCRIPT = 'ts', - JSX = 'jsx', - TSX = 'tsx', - CSS = 'css', - HTML = 'html', - JSON = 'json', - XML = 'xml', - YAML = 'yaml', - CSV = 'csv', - LOG = 'log', - PYTHON = 'python', - JAVA = 'java', - CPP = 'cpp', - PHP = 'php', - RUBY = 'ruby', - GO = 'go', - RUST = 'rust', - SHELL = 'shell', - SQL = 'sql', - R = 'r', - SCALA = 'scala', - KOTLIN = 'kotlin', - SWIFT = 'swift', - DART = 'dart', - VUE = 'vue', - SVELTE = 'svelte', - LATEX = 'latex', - BIBTEX = 'bibtex', - CUDA = 'cuda', - VULKAN = 'vulkan', - HASKELL = 'haskell', - CSHARP = 'csharp', - PROPERTIES = 'properties' -} - -// File extension enums -export enum FileExtensionImage { - JPG = '.jpg', - JPEG = '.jpeg', - PNG = '.png', - GIF = '.gif', - WEBP = '.webp', - SVG = '.svg' -} - -export enum FileExtensionAudio { - MP3 = '.mp3', - WAV = '.wav' -} - -export enum FileExtensionPdf { - PDF = '.pdf' -} - -export enum FileExtensionText { - TXT = '.txt', - MD = '.md', - ADOC = '.adoc', - JS = '.js', - TS = '.ts', - JSX = '.jsx', - TSX = '.tsx', - CSS = '.css', - HTML = '.html', - HTM = '.htm', - JSON = '.json', - XML = '.xml', - YAML = '.yaml', - YML = '.yml', - CSV = '.csv', - LOG = '.log', - PY = '.py', - JAVA = '.java', - CPP = '.cpp', - C = '.c', - H = '.h', - PHP = '.php', - RB = '.rb', - GO = '.go', - RS = '.rs', - SH = '.sh', - BAT = '.bat', - SQL = '.sql', - R = '.r', - SCALA = '.scala', - KT = '.kt', - SWIFT = '.swift', - DART = '.dart', - VUE = '.vue', - SVELTE = '.svelte', - TEX = '.tex', - BIB = '.bib', - CU = '.cu', - CUH = '.cuh', - COMP = '.comp', - HPP = '.hpp', - HS = '.hs', - PROPERTIES = '.properties', - CS = '.cs' -} - -// MIME type prefixes and includes for content detection -export enum MimeTypePrefix { - IMAGE = 'image/', - TEXT = 'text' -} - -export enum MimeTypeIncludes { - JSON = 'json', - JAVASCRIPT = 'javascript', - TYPESCRIPT = 'typescript' -} - -// URI patterns for content detection -export enum UriPattern { - DATABASE_KEYWORD = 'database', - DATABASE_SCHEME = 'db://' -} - -// MIME type enums -export enum MimeTypeApplication { - PDF = 'application/pdf', - OCTET_STREAM = 'application/octet-stream' -} - -export enum MimeTypeAudio { - MP3_MPEG = 'audio/mpeg', - MP3 = 'audio/mp3', - MP4 = 'audio/mp4', - WAV = 'audio/wav', - WEBM = 'audio/webm', - WEBM_OPUS = 'audio/webm;codecs=opus' -} - -export enum MimeTypeImage { - JPEG = 'image/jpeg', - JPG = 'image/jpg', - PNG = 'image/png', - GIF = 'image/gif', - WEBP = 'image/webp', - SVG = 'image/svg+xml', - ICO = 'image/x-icon', - ICO_MICROSOFT = 'image/vnd.microsoft.icon' -} - -export enum MimeTypeText { - PLAIN = 'text/plain', - MARKDOWN = 'text/markdown', - ASCIIDOC = 'text/asciidoc', - JAVASCRIPT = 'text/javascript', - JAVASCRIPT_APP = 'application/javascript', - TYPESCRIPT = 'text/typescript', - JSX = 'text/jsx', - TSX = 'text/tsx', - CSS = 'text/css', - HTML = 'text/html', - JSON = 'application/json', - XML_TEXT = 'text/xml', - XML_APP = 'application/xml', - YAML_TEXT = 'text/yaml', - YAML_APP = 'application/yaml', - CSV = 'text/csv', - PYTHON = 'text/x-python', - JAVA = 'text/x-java-source', - CPP_HDR = 'text/x-c++hdr', - CPP_SRC = 'text/x-c++src', - CSHARP = 'text/x-csharp', - HASKELL = 'text/x-haskell', - C_SRC = 'text/x-csrc', - C_HDR = 'text/x-chdr', - PHP = 'text/x-php', - RUBY = 'text/x-ruby', - GO = 'text/x-go', - RUST = 'text/x-rust', - SHELL = 'text/x-shellscript', - BAT = 'application/x-bat', - SQL = 'text/x-sql', - R = 'text/x-r', - SCALA = 'text/x-scala', - KOTLIN = 'text/x-kotlin', - SWIFT = 'text/x-swift', - DART = 'text/x-dart', - VUE = 'text/x-vue', - SVELTE = 'text/x-svelte', - TEX = 'text/x-tex', - TEX_APP = 'application/x-tex', - LATEX = 'application/x-latex', - BIBTEX = 'text/x-bibtex', - CUDA = 'text/x-cuda', - PROPERTIES = 'text/properties' -} diff --git a/tools/server/webui/src/lib/enums/index.ts b/tools/server/webui/src/lib/enums/index.ts deleted file mode 100644 index 56e1d9f4a..000000000 --- a/tools/server/webui/src/lib/enums/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -export { - AttachmentType, - AttachmentMenuItemId, - AttachmentItemEnabledWhen, - AttachmentAction, - AttachmentItemVisibleWhen -} from './attachment'; - -export { AgenticSectionType, ToolCallType } from './agentic'; - -export { - ChatMessageStatsView, - ContentPartType, - ConversationSelectionMode, - ErrorDialogType, - MessageRole, - MessageType, - PdfViewMode, - ReasoningFormat -} from './chat'; - -export { - FileTypeCategory, - FileTypeImage, - FileTypeAudio, - FileTypePdf, - FileTypeText, - FileExtensionImage, - FileExtensionAudio, - FileExtensionPdf, - FileExtensionText, - MimeTypePrefix, - MimeTypeIncludes, - UriPattern, - MimeTypeApplication, - MimeTypeAudio, - MimeTypeImage, - MimeTypeText, - SpecialFileType -} from './files'; - -export { - MCPConnectionPhase, - MCPLogLevel, - MCPTransportType, - HealthCheckStatus, - MCPContentType, - MCPRefType, - JsonSchemaType -} from './mcp'; - -export { ModelModality } from './model'; - -export { ServerRole, ServerModelStatus } from './server'; - -export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings'; - -export { ColorMode, HtmlInputType, McpPromptVariant, TooltipSide, UrlProtocol } from './ui'; - -export { KeyboardKey } from './keyboard'; - -export { ToolSource, ToolPermissionDecision, ToolResponseField } from './tools'; diff --git a/tools/server/webui/src/lib/enums/keyboard.ts b/tools/server/webui/src/lib/enums/keyboard.ts deleted file mode 100644 index 46cd4a776..000000000 --- a/tools/server/webui/src/lib/enums/keyboard.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Keyboard key names for event handling - */ -export enum KeyboardKey { - ENTER = 'Enter', - ESCAPE = 'Escape', - ARROW_UP = 'ArrowUp', - ARROW_DOWN = 'ArrowDown', - ARROW_LEFT = 'ArrowLeft', - ARROW_RIGHT = 'ArrowRight', - TAB = 'Tab', - D_LOWER = 'd', - D_UPPER = 'D', - E_UPPER = 'E', - K_LOWER = 'k', - O_LOWER = 'o', - O_UPPER = 'O', - SPACE = ' ' -} diff --git a/tools/server/webui/src/lib/enums/mcp.ts b/tools/server/webui/src/lib/enums/mcp.ts deleted file mode 100644 index d2c27e1a0..000000000 --- a/tools/server/webui/src/lib/enums/mcp.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Connection lifecycle phases for MCP protocol - */ -export enum MCPConnectionPhase { - IDLE = 'idle', - TRANSPORT_CREATING = 'transport_creating', - TRANSPORT_READY = 'transport_ready', - INITIALIZING = 'initializing', - CAPABILITIES_EXCHANGED = 'capabilities_exchanged', - LISTING_TOOLS = 'listing_tools', - CONNECTED = 'connected', - ERROR = 'error', - DISCONNECTED = 'disconnected' -} - -/** - * Log level for connection events - */ -export enum MCPLogLevel { - INFO = 'info', - WARN = 'warn', - ERROR = 'error' -} - -/** - * Transport types for MCP connections - */ -export enum MCPTransportType { - WEBSOCKET = 'websocket', - STREAMABLE_HTTP = 'streamable_http', - SSE = 'sse' -} - -/** - * Health check status for MCP servers - */ -export enum HealthCheckStatus { - IDLE = 'idle', - CONNECTING = 'connecting', - SUCCESS = 'success', - ERROR = 'error' -} - -/** - * Content types for MCP tool results - */ -export enum MCPContentType { - TEXT = 'text', - IMAGE = 'image', - RESOURCE = 'resource' -} - -/** - * JSON Schema types used in MCP tool definitions - */ -export enum JsonSchemaType { - OBJECT = 'object' -} - -/** - * Reference types for MCP completions - */ -export enum MCPRefType { - PROMPT = 'ref/prompt', - RESOURCE = 'ref/resource' -} diff --git a/tools/server/webui/src/lib/enums/model.ts b/tools/server/webui/src/lib/enums/model.ts deleted file mode 100644 index 7729ecfea..000000000 --- a/tools/server/webui/src/lib/enums/model.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum ModelModality { - TEXT = 'TEXT', - AUDIO = 'AUDIO', - VISION = 'VISION' -} diff --git a/tools/server/webui/src/lib/enums/server.ts b/tools/server/webui/src/lib/enums/server.ts deleted file mode 100644 index c9d599c52..000000000 --- a/tools/server/webui/src/lib/enums/server.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Server role enum - used for single/multi-model mode - */ -export enum ServerRole { - /** Single model mode - server running with a specific model loaded */ - MODEL = 'model', - /** Router mode - server managing multiple model instances */ - ROUTER = 'router' -} - -/** - * Model status enum - matches tools/server/server-models.h from C++ server - * Used as the `value` field in the status object from /models endpoint - */ -export enum ServerModelStatus { - UNLOADED = 'unloaded', - LOADING = 'loading', - LOADED = 'loaded', - SLEEPING = 'sleeping', - FAILED = 'failed' -} diff --git a/tools/server/webui/src/lib/enums/settings.ts b/tools/server/webui/src/lib/enums/settings.ts deleted file mode 100644 index f17f21976..000000000 --- a/tools/server/webui/src/lib/enums/settings.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Parameter source - indicates whether a parameter uses default or custom value - */ -export enum ParameterSource { - DEFAULT = 'default', - CUSTOM = 'custom' -} - -/** - * Syncable parameter type - data types for parameters that can be synced with server - */ -export enum SyncableParameterType { - NUMBER = 'number', - STRING = 'string', - BOOLEAN = 'boolean' -} - -/** - * Settings field type - defines the input type for settings fields - */ -export enum SettingsFieldType { - INPUT = 'input', - TEXTAREA = 'textarea', - CHECKBOX = 'checkbox', - SELECT = 'select' -} diff --git a/tools/server/webui/src/lib/enums/tools.ts b/tools/server/webui/src/lib/enums/tools.ts deleted file mode 100644 index 4b2cdab32..000000000 --- a/tools/server/webui/src/lib/enums/tools.ts +++ /dev/null @@ -1,17 +0,0 @@ -export enum ToolSource { - BUILTIN = 'builtin', - MCP = 'mcp', - CUSTOM = 'custom' -} - -export enum ToolPermissionDecision { - ALWAYS = 'always', - ALWAYS_SERVER = 'always_server', - ONCE = 'once', - DENY = 'deny' -} - -export enum ToolResponseField { - PLAIN_TEXT = 'plain_text_response', - ERROR = 'error' -} diff --git a/tools/server/webui/src/lib/enums/ui.ts b/tools/server/webui/src/lib/enums/ui.ts deleted file mode 100644 index 829963794..000000000 --- a/tools/server/webui/src/lib/enums/ui.ts +++ /dev/null @@ -1,35 +0,0 @@ -export enum ColorMode { - LIGHT = 'light', - DARK = 'dark', - SYSTEM = 'system' -} - -export enum TooltipSide { - TOP = 'top', - RIGHT = 'right', - BOTTOM = 'bottom', - LEFT = 'left' -} - -/** - * MCP prompt display variant - */ -export enum McpPromptVariant { - MESSAGE = 'message', - ATTACHMENT = 'attachment' -} - -/** - * URL prefixes for protocol detection - */ -export enum UrlProtocol { - DATA = 'data:', - HTTP = 'http:', - HTTPS = 'https:', - WEBSOCKET = 'ws:', - WEBSOCKET_SECURE = 'wss:' -} - -export enum HtmlInputType { - FILE = 'file' -} diff --git a/tools/server/webui/src/lib/hooks/is-mobile.svelte.ts b/tools/server/webui/src/lib/hooks/is-mobile.svelte.ts deleted file mode 100644 index 6454fc5b5..000000000 --- a/tools/server/webui/src/lib/hooks/is-mobile.svelte.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DEFAULT_MOBILE_BREAKPOINT } from '$lib/constants'; -import { MediaQuery } from 'svelte/reactivity'; - -export class IsMobile extends MediaQuery { - constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { - super(`max-width: ${breakpoint - 1}px`); - } -} diff --git a/tools/server/webui/src/lib/hooks/use-attachment-menu.svelte.ts b/tools/server/webui/src/lib/hooks/use-attachment-menu.svelte.ts deleted file mode 100644 index ddb999485..000000000 --- a/tools/server/webui/src/lib/hooks/use-attachment-menu.svelte.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { page } from '$app/state'; -import { AttachmentAction } from '$lib/enums'; - -export interface AttachmentModalityFlags { - hasVisionModality: boolean; - hasAudioModality: boolean; - hasMcpPromptsSupport: boolean; - hasMcpResourcesSupport: boolean; -} - -export interface AttachmentActionCallbacks { - onFileUpload?: () => void; - onSystemPromptClick?: () => void; - onMcpPromptClick?: () => void; - onMcpResourcesClick?: () => void; -} - -export interface UseAttachmentMenuReturn { - readonly callbacks: Record void>; - isItemEnabled(enabledWhen: string | undefined): boolean; - isItemVisible(visibleWhen: string | undefined): boolean; - getSystemMessageTooltip(): string; -} - -/** - * useAttachmentMenu - Shared logic for attachment menu components. - * - * Encapsulates the modality-flag checks and callback wrapping that is - * identical across the desktop dropdown (`ChatFormActionAddDropdown`) - * and the mobile sheet (`ChatFormActionAddSheet`). - * - * @param getFlags - Getter returning the current modality capability flags. - * @param getCallbacks - Getter returning the raw action callbacks from props. - * @param close - Function that dismisses the hosting UI element (dropdown / sheet). - */ -export function useAttachmentMenu( - getFlags: () => AttachmentModalityFlags, - getCallbacks: () => AttachmentActionCallbacks, - close: () => void -): UseAttachmentMenuReturn { - const modalityFlags = $derived(getFlags()); - - const callbacks = $derived.by(() => { - const cbs = getCallbacks(); - const wrap = (fn?: () => void) => () => { - close(); - fn?.(); - }; - return { - [AttachmentAction.FILE_UPLOAD]: wrap(cbs.onFileUpload), - [AttachmentAction.SYSTEM_PROMPT_CLICK]: wrap(cbs.onSystemPromptClick), - [AttachmentAction.MCP_PROMPT_CLICK]: wrap(cbs.onMcpPromptClick), - [AttachmentAction.MCP_RESOURCES_CLICK]: wrap(cbs.onMcpResourcesClick) - }; - }); - - function isItemEnabled(enabledWhen: string | undefined): boolean { - if (!enabledWhen || enabledWhen === 'always') return true; - return !!modalityFlags[enabledWhen as keyof AttachmentModalityFlags]; - } - - function isItemVisible(visibleWhen: string | undefined): boolean { - if (!visibleWhen) return true; - return !!modalityFlags[visibleWhen as keyof AttachmentModalityFlags]; - } - - function getSystemMessageTooltip(): string { - return !page.params.id - ? 'Add custom system message for a new conversation' - : 'Inject custom system message at the beginning of the conversation'; - } - - return { - get callbacks() { - return callbacks; - }, - isItemEnabled, - isItemVisible, - getSystemMessageTooltip - }; -} diff --git a/tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts b/tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts deleted file mode 100644 index f59e3ed4b..000000000 --- a/tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants'; - -export interface AutoScrollOptions { - disabled?: boolean; -} - -/** - * Creates an auto-scroll controller for a scrollable container. - * - * Features: - * - Auto-scrolls to bottom during streaming/loading - * - Stops auto-scroll when user manually scrolls up - * - Resumes auto-scroll when user scrolls back to bottom - */ -export class AutoScrollController { - private _autoScrollEnabled = $state(true); - private _userScrolledUp = $state(false); - private _lastScrollTop = $state(0); - private _scrollInterval: ReturnType | undefined; - private _container: HTMLElement | undefined; - private _disabled: boolean; - private _mutationObserver: MutationObserver | null = null; - private _rafPending = false; - private _observerEnabled = false; - constructor(options: AutoScrollOptions = {}) { - this._disabled = options.disabled ?? false; - } - - get autoScrollEnabled(): boolean { - return this._autoScrollEnabled; - } - - get userScrolledUp(): boolean { - return this._userScrolledUp; - } - - /** - * Binds the controller to a scrollable container element. - */ - setContainer(container: HTMLElement | undefined): void { - this._doStopObserving(); - this._container = container; - - if (this._observerEnabled && container && !this._disabled) { - this._doStartObserving(); - } - } - - /** - * Updates the disabled state. - */ - setDisabled(disabled: boolean): void { - if (this._disabled === disabled) return; - this._disabled = disabled; - if (disabled) { - this._autoScrollEnabled = false; - this.stopInterval(); - this._doStopObserving(); - } else if (this._observerEnabled && this._container && !this._mutationObserver) { - this._doStartObserving(); - } - } - - /** - * Handles scroll events to detect user scroll direction and toggle auto-scroll. - */ - handleScroll(): void { - if (this._disabled || !this._container) return; - - const { scrollTop, scrollHeight, clientHeight } = this._container; - const distanceFromBottom = scrollHeight - clientHeight - scrollTop; - const isScrollingUp = scrollTop < this._lastScrollTop; - const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD; - - if (isScrollingUp && !isAtBottom) { - this._userScrolledUp = true; - this._autoScrollEnabled = false; - } else if (isAtBottom && this._userScrolledUp) { - this._userScrolledUp = false; - this._autoScrollEnabled = true; - } - - this._lastScrollTop = scrollTop; - } - - /** - * Scrolls the container to the bottom. - */ - scrollToBottom(behavior: ScrollBehavior = 'smooth'): void { - if (this._disabled || !this._container) return; - this._container.scrollTo({ top: this._container.scrollHeight, behavior }); - } - - /** - * Enables auto-scroll (e.g., when user sends a message). - */ - enable(): void { - if (this._disabled) return; - this._userScrolledUp = false; - this._autoScrollEnabled = true; - } - - /** - * Starts the auto-scroll interval for continuous scrolling during streaming. - */ - startInterval(): void { - if (this._disabled || this._scrollInterval) return; - - this._scrollInterval = setInterval(() => { - this.scrollToBottom(); - }, AUTO_SCROLL_INTERVAL); - } - - /** - * Stops the auto-scroll interval. - */ - stopInterval(): void { - if (this._scrollInterval) { - clearInterval(this._scrollInterval); - this._scrollInterval = undefined; - } - } - - /** - * Updates the auto-scroll interval based on streaming state. - * Call this in a $effect to automatically manage the interval. - */ - updateInterval(isStreaming: boolean): void { - if (this._disabled) { - this.stopInterval(); - return; - } - - if (isStreaming && this._autoScrollEnabled) { - if (!this._scrollInterval) { - this.startInterval(); - } - } else { - this.stopInterval(); - } - } - - /** - * Cleans up resources. Call this in onDestroy or when the component unmounts. - */ - destroy(): void { - this.stopInterval(); - this._doStopObserving(); - } - - /** - * Starts a MutationObserver on the container that auto-scrolls to bottom - * on content changes. More responsive than interval-based polling. - */ - startObserving(): void { - this._observerEnabled = true; - - if (this._container && !this._disabled && !this._mutationObserver) { - this._doStartObserving(); - } - } - - /** - * Stops the MutationObserver. - */ - stopObserving(): void { - this._observerEnabled = false; - this._doStopObserving(); - } - - private _doStartObserving(): void { - if (!this._container || this._mutationObserver) return; - - this._mutationObserver = new MutationObserver(() => { - if (!this._autoScrollEnabled || this._rafPending) return; - this._rafPending = true; - requestAnimationFrame(() => { - this._rafPending = false; - if (this._autoScrollEnabled && this._container) { - this._container.scrollTop = this._container.scrollHeight; - } - }); - }); - - this._mutationObserver.observe(this._container, { - childList: true, - subtree: true, - characterData: true - }); - } - - private _doStopObserving(): void { - if (this._mutationObserver) { - this._mutationObserver.disconnect(); - this._mutationObserver = null; - } - this._rafPending = false; - } -} - -/** - * Creates a new AutoScrollController instance. - */ -export function createAutoScrollController(options: AutoScrollOptions = {}): AutoScrollController { - return new AutoScrollController(options); -} diff --git a/tools/server/webui/src/lib/hooks/use-draft-messages.svelte.ts b/tools/server/webui/src/lib/hooks/use-draft-messages.svelte.ts deleted file mode 100644 index 11305b205..000000000 --- a/tools/server/webui/src/lib/hooks/use-draft-messages.svelte.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { onMount } from 'svelte'; -import { afterNavigate, beforeNavigate } from '$app/navigation'; -import { draftMessagesStore } from '$lib/stores/draft-messages.svelte'; - -interface UseDraftMessagesOptions { - getChatId: () => string | undefined; - getMessage: () => string; - getFiles: () => ChatUploadedFile[]; - setMessage: (message: string) => void; - setFiles: (files: ChatUploadedFile[]) => void; - getInitialMessage: () => string; -} - -export function useDraftMessages(options: UseDraftMessagesOptions) { - onMount(() => { - const chatId = options.getChatId(); - const draft = draftMessagesStore.getDraftMessage(chatId); - - if ((draft.message || draft.files.length > 0) && !options.getInitialMessage()) { - options.setMessage(draft.message); - options.setFiles(draft.files); - } - }); - - beforeNavigate(() => { - const chatId = options.getChatId(); - draftMessagesStore.saveDraftMessage(chatId, options.getMessage(), options.getFiles()); - }); - - afterNavigate((navigation) => { - if (navigation?.from != null) { - const chatId = options.getChatId(); - const draft = draftMessagesStore.getDraftMessage(chatId); - options.setMessage(draft.message); - options.setFiles(draft.files); - } - }); - - function clearDraft() { - const chatId = options.getChatId(); - draftMessagesStore.clearDraftMessage(chatId); - } - - return { clearDraft }; -} diff --git a/tools/server/webui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts b/tools/server/webui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts deleted file mode 100644 index 05966a1a1..000000000 --- a/tools/server/webui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { goto } from '$app/navigation'; -import { KeyboardKey } from '$lib/enums'; -import { ROUTES } from '$lib/constants/routes'; - -interface KeyboardShortcutsCallbacks { - activateSearchMode?: () => void; - editActiveConversation?: () => void; - onSearchActivated?: () => void; - deleteActiveConversation?: () => void; - navigateToPrevConversation?: () => void; - navigateToNextConversation?: () => void; -} - -export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) { - function handleKeydown(event: KeyboardEvent) { - const isCmdOrCtrl = event.metaKey || event.ctrlKey; - - if (isCmdOrCtrl && event.key === KeyboardKey.K_LOWER) { - event.preventDefault(); - callbacks.activateSearchMode?.(); - callbacks.onSearchActivated?.(); - } - - if ( - isCmdOrCtrl && - event.shiftKey && - (event.key === KeyboardKey.O_LOWER || event.key === KeyboardKey.O_UPPER) - ) { - event.preventDefault(); - - goto(ROUTES.NEW_CHAT); - } - - if (event.shiftKey && isCmdOrCtrl && event.key === KeyboardKey.E_UPPER) { - event.preventDefault(); - callbacks.editActiveConversation?.(); - } - - if ( - isCmdOrCtrl && - event.shiftKey && - (event.key === KeyboardKey.D_LOWER || event.key === KeyboardKey.D_UPPER) - ) { - event.preventDefault(); - callbacks.deleteActiveConversation?.(); - } - - if (isCmdOrCtrl && event.shiftKey && event.key === KeyboardKey.ARROW_UP) { - event.preventDefault(); - callbacks.navigateToPrevConversation?.(); - } - - if (isCmdOrCtrl && event.shiftKey && event.key === KeyboardKey.ARROW_DOWN) { - event.preventDefault(); - callbacks.navigateToNextConversation?.(); - } - } - - return { handleKeydown }; -} diff --git a/tools/server/webui/src/lib/hooks/use-message-edit-context.svelte.ts b/tools/server/webui/src/lib/hooks/use-message-edit-context.svelte.ts deleted file mode 100644 index 71d1b66f8..000000000 --- a/tools/server/webui/src/lib/hooks/use-message-edit-context.svelte.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { setMessageEditContext } from '$lib/contexts'; -import { MessageRole } from '$lib/enums'; -import { parseFilesToMessageExtras } from '$lib/utils/convert-files-to-extra'; - -interface UseMessageEditContextOptions { - getContent: () => string; - getExtras: () => DatabaseMessageExtra[]; - showSaveOnlyOption?: boolean; - onSave: (content: string, extras?: DatabaseMessageExtra[]) => void; -} - -export function useMessageEditContext(options: UseMessageEditContextOptions) { - let isEditing = $state(false); - let editedContent = $state(''); - let editedExtras = $state([]); - let editedUploadedFiles = $state([]); - - function handleEdit() { - editedContent = options.getContent(); - editedExtras = [...options.getExtras()]; - editedUploadedFiles = []; - isEditing = true; - } - - async function handleSaveEdit() { - const trimmed = editedContent.trim(); - if (!trimmed && editedExtras.length === 0 && editedUploadedFiles.length === 0) return; - - let finalExtras: DatabaseMessageExtra[] = $state.snapshot(editedExtras); - if (editedUploadedFiles.length > 0) { - const plainFiles = $state.snapshot(editedUploadedFiles); - const result = await parseFilesToMessageExtras(plainFiles); - const newExtras = result?.extras || []; - finalExtras = [...finalExtras, ...newExtras]; - } - - options.onSave(trimmed, finalExtras.length > 0 ? finalExtras : undefined); - isEditing = false; - } - - function handleCancelEdit() { - isEditing = false; - } - - setMessageEditContext({ - get isEditing() { - return isEditing; - }, - get editedContent() { - return editedContent; - }, - get editedExtras() { - return editedExtras; - }, - get editedUploadedFiles() { - return editedUploadedFiles; - }, - get originalContent() { - return options.getContent(); - }, - get originalExtras() { - return options.getExtras(); - }, - get showSaveOnlyOption() { - return options.showSaveOnlyOption ?? false; - }, - get showBranchAfterEditOption() { - return false; - }, - get shouldBranchAfterEdit() { - return false; - }, - get messageRole() { - return MessageRole.USER; - }, - setContent: (c: string) => { - editedContent = c; - }, - setExtras: (e: DatabaseMessageExtra[]) => { - editedExtras = e; - }, - setUploadedFiles: (f: ChatUploadedFile[]) => { - editedUploadedFiles = f; - }, - save: handleSaveEdit, - saveOnly: handleSaveEdit, - cancel: handleCancelEdit, - startEdit: handleEdit - }); - - return { - get isEditing() { - return isEditing; - }, - handleEdit, - handleSaveEdit, - handleCancelEdit - }; -} diff --git a/tools/server/webui/src/lib/hooks/use-models-selector.svelte.ts b/tools/server/webui/src/lib/hooks/use-models-selector.svelte.ts deleted file mode 100644 index 3ae4865cf..000000000 --- a/tools/server/webui/src/lib/hooks/use-models-selector.svelte.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { onMount } from 'svelte'; -import { - modelsStore, - modelOptions, - modelsLoading, - modelsUpdating, - selectedModelId, - singleModelName -} from '$lib/stores/models.svelte'; -import { isRouterMode } from '$lib/stores/server.svelte'; -import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils'; -import type { ModelOption } from '$lib/types/models'; - -export interface UseModelsSelectorOptions { - currentModel: () => string | null; - useGlobalSelection?: () => boolean; - onModelChange?: () => - | ((modelId: string, modelName: string) => Promise | boolean | void) - | undefined; - onOpenChange?: (open: boolean) => void; -} - -export interface UseModelsSelectorReturn { - readonly options: ModelOption[]; - readonly loading: boolean; - readonly updating: boolean; - readonly activeId: string | null; - readonly isRouter: boolean; - readonly serverModel: string | null; - readonly isHighlightedCurrentModelActive: boolean; - readonly isCurrentModelInCache: boolean; - readonly filteredOptions: ModelOption[]; - readonly groupedFilteredOptions: ReturnType; - readonly isLoadingModel: boolean; - readonly searchTerm: string; - readonly showModelDialog: boolean; - readonly infoModelId: string | null; - setSearchTerm(value: string): void; - setShowModelDialog(value: boolean): void; - handleInfoClick(modelName: string): void; - handleSelect(modelId: string): Promise; - handleOpenChange(open: boolean): void; - isFavorite(model: string): boolean; - getDisplayOption(): ModelOption | undefined; -} - -/** - * Shared reactive state and logic for model selection. - * - * Used by both the desktop dropdown (`ModelsSelectorDropdown`) - * and the mobile sheet (`ModelsSelectorSheet`) to avoid - * duplicating store derivations, selection handling, and model loading. - */ -export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn { - const options = $derived( - modelOptions().filter((option) => { - const modelProps = modelsStore.getModelProps(option.model); - return modelProps?.webui !== false; - }) - ); - const loading = $derived(modelsLoading()); - const updating = $derived(modelsUpdating()); - const activeId = $derived(selectedModelId()); - const isRouter = $derived(isRouterMode()); - const serverModel = $derived(singleModelName()); - - const currentModel = $derived(opts.currentModel()); - const useGlobalSelection = $derived(opts.useGlobalSelection?.() ?? false); - const onModelChange = $derived(opts.onModelChange?.()); - - const isHighlightedCurrentModelActive = $derived.by(() => { - if (!isRouter || !currentModel) return false; - const currentOption = options.find((option) => option.model === currentModel); - return currentOption ? currentOption.id === activeId : false; - }); - - const isCurrentModelInCache = $derived.by(() => { - if (!isRouter || !currentModel) return true; - return options.some((option) => option.model === currentModel); - }); - - let isLoadingModel = $state(false); - let searchTerm = $state(''); - let showModelDialog = $state(false); - let infoModelId = $state(null); - const filteredOptions = $derived(filterModelOptions(options, searchTerm)); - const groupedFilteredOptions = $derived( - groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) => - modelsStore.isModelLoaded(m) - ) - ); - - function handleInfoClick(modelName: string) { - infoModelId = modelName; - showModelDialog = true; - } - - onMount(() => { - modelsStore.fetch().catch((error) => { - console.error('Unable to load models:', error); - }); - }); - - function handleOpenChange(open: boolean) { - if (loading || updating) return; - - if (isRouter) { - searchTerm = ''; - - if (open) { - modelsStore.fetchRouterModels().then(() => { - modelsStore.fetchModalitiesForLoadedModels(); - }); - } - - opts.onOpenChange?.(open); - } else { - showModelDialog = open; - } - } - - async function handleSelect(modelId: string) { - const option = options.find((opt) => opt.id === modelId); - if (!option) return; - - let shouldCloseMenu = true; - - if (onModelChange) { - const result = await onModelChange(option.id, option.model); - if (result === false) { - shouldCloseMenu = false; - } - } else { - await modelsStore.selectModelById(option.id); - } - - if (shouldCloseMenu) { - handleOpenChange(false); - - requestAnimationFrame(() => { - const textarea = document.querySelector( - '[data-slot="chat-form"] textarea' - ); - textarea?.focus(); - }); - } - - if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) { - isLoadingModel = true; - modelsStore - .loadModel(option.model) - .catch((error) => console.error('Failed to load model:', error)) - .finally(() => (isLoadingModel = false)); - } - } - - function getDisplayOption(): ModelOption | undefined { - if (!isRouter) { - const displayModel = serverModel || currentModel; - if (displayModel) { - return { - id: serverModel ? 'current' : 'offline-current', - model: displayModel, - name: displayModel.split('/').pop() || displayModel, - capabilities: [] - }; - } - return undefined; - } - - if (useGlobalSelection && activeId) { - const selected = options.find((option) => option.id === activeId); - if (selected) return selected; - } - - if (currentModel) { - if (!isCurrentModelInCache) { - return { - id: 'not-in-cache', - model: currentModel, - name: currentModel.split('/').pop() || currentModel, - capabilities: [] - }; - } - return options.find((option) => option.model === currentModel); - } - - if (activeId) { - return options.find((option) => option.id === activeId); - } - - return undefined; - } - - return { - get options() { - return options; - }, - get loading() { - return loading; - }, - get updating() { - return updating; - }, - get activeId() { - return activeId; - }, - get isRouter() { - return isRouter; - }, - get serverModel() { - return serverModel; - }, - get isHighlightedCurrentModelActive() { - return isHighlightedCurrentModelActive; - }, - get isCurrentModelInCache() { - return isCurrentModelInCache; - }, - get filteredOptions() { - return filteredOptions; - }, - get groupedFilteredOptions() { - return groupedFilteredOptions; - }, - get isLoadingModel() { - return isLoadingModel; - }, - get searchTerm() { - return searchTerm; - }, - get showModelDialog() { - return showModelDialog; - }, - get infoModelId() { - return infoModelId; - }, - setSearchTerm(value: string) { - searchTerm = value; - }, - setShowModelDialog(value: boolean) { - showModelDialog = value; - }, - handleInfoClick, - handleSelect, - handleOpenChange, - isFavorite(model: string) { - return modelsStore.favoriteModelIds.has(model); - }, - getDisplayOption - }; -} diff --git a/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts b/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts deleted file mode 100644 index f28031972..000000000 --- a/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { activeProcessingState } from '$lib/stores/chat.svelte'; -import { config } from '$lib/stores/settings.svelte'; -import { STATS_UNITS } from '$lib/constants'; -import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } from '$lib/types'; - -export interface UseProcessingStateReturn { - readonly processingState: ApiProcessingState | null; - getProcessingDetails(): string[]; - getTechnicalDetails(): string[]; - getProcessingMessage(): string; - getPromptProgressText(): string | null; - getLiveProcessingStats(): LiveProcessingStats | null; - getLiveGenerationStats(): LiveGenerationStats | null; - shouldShowDetails(): boolean; - startMonitoring(): void; - stopMonitoring(): void; -} - -/** - * useProcessingState - Reactive processing state hook - * - * This hook provides reactive access to the processing state of the server. - * It directly reads from chatStore's reactive state and provides - * formatted processing details for UI display. - * - * **Features:** - * - Real-time processing state via direct reactive state binding - * - Context and output token tracking - * - Tokens per second calculation - * - Automatic updates when streaming data arrives - * - Supports multiple concurrent conversations - * - * @returns Hook interface with processing state and control methods - */ -export function useProcessingState(): UseProcessingStateReturn { - let isMonitoring = $state(false); - let lastKnownState = $state(null); - let lastKnownProcessingStats = $state(null); - - // Derive processing state reactively from chatStore's direct state - const processingState = $derived.by(() => { - if (!isMonitoring) { - return lastKnownState; - } - // Read directly from the reactive state export - return activeProcessingState(); - }); - - // Track last known state for keepStatsVisible functionality - $effect(() => { - if (processingState && isMonitoring) { - lastKnownState = processingState; - } - }); - - // Track last known processing stats for when promptProgress disappears - $effect(() => { - if (processingState?.promptProgress) { - const { processed, total, time_ms, cache } = processingState.promptProgress; - const actualProcessed = processed - cache; - const actualTotal = total - cache; - - if (actualProcessed > 0 && time_ms > 0) { - const tokensPerSecond = actualProcessed / (time_ms / 1000); - lastKnownProcessingStats = { - tokensProcessed: actualProcessed, - totalTokens: actualTotal, - timeMs: time_ms, - tokensPerSecond - }; - } - } - }); - - function getETASecs(done: number, total: number, elapsedMs: number): number | undefined { - const elapsedSecs = elapsedMs / 1000; - const progressETASecs = - done === 0 || elapsedSecs < 0.5 - ? undefined // can be the case for the 0% progress report - : elapsedSecs * (total / done - 1); - return progressETASecs; - } - - function startMonitoring(): void { - if (isMonitoring) return; - isMonitoring = true; - } - - function stopMonitoring(): void { - if (!isMonitoring) return; - isMonitoring = false; - - // Only clear last known state if keepStatsVisible is disabled - const currentConfig = config(); - if (!currentConfig.keepStatsVisible) { - lastKnownState = null; - lastKnownProcessingStats = null; - } - } - - function getProcessingMessage(): string { - if (!processingState) { - return 'Processing...'; - } - - switch (processingState.status) { - case 'initializing': - return 'Initializing...'; - case 'preparing': - if (processingState.progressPercent !== undefined) { - return `Processing (${processingState.progressPercent}%)`; - } - return 'Preparing response...'; - case 'generating': - return ''; - default: - return 'Processing...'; - } - } - - function getProcessingDetails(): string[] { - // Use current processing state or fall back to last known state - const stateToUse = processingState || lastKnownState; - if (!stateToUse) { - return []; - } - - const details: string[] = []; - - // Show prompt processing progress with ETA during preparation phase - if (stateToUse.promptProgress) { - const { processed, total, time_ms, cache } = stateToUse.promptProgress; - const actualProcessed = processed - cache; - const actualTotal = total - cache; - - if (actualProcessed < actualTotal && actualProcessed > 0) { - const percent = Math.round((actualProcessed / actualTotal) * 100); - const eta = getETASecs(actualProcessed, actualTotal, time_ms); - - if (eta !== undefined) { - const etaSecs = Math.ceil(eta); - details.push(`Processing ${percent}% (ETA: ${etaSecs}s)`); - } else { - details.push(`Processing ${percent}%`); - } - } - } - - // Always show context info when we have valid data - if ( - typeof stateToUse.contextTotal === 'number' && - stateToUse.contextUsed >= 0 && - stateToUse.contextTotal > 0 - ) { - const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100); - - details.push( - `Context: ${stateToUse.contextUsed}/${stateToUse.contextTotal} (${contextPercent}%)` - ); - } - - if (stateToUse.outputTokensUsed > 0) { - // Handle infinite max_tokens (-1) case - if (stateToUse.outputTokensMax <= 0) { - details.push(`Output: ${stateToUse.outputTokensUsed}/∞`); - } else { - const outputPercent = Math.round( - (stateToUse.outputTokensUsed / stateToUse.outputTokensMax) * 100 - ); - - details.push( - `Output: ${stateToUse.outputTokensUsed}/${stateToUse.outputTokensMax} (${outputPercent}%)` - ); - } - } - - if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) { - details.push(`${stateToUse.tokensPerSecond.toFixed(1)} ${STATS_UNITS.TOKENS_PER_SECOND}`); - } - - if (stateToUse.speculative) { - details.push('Speculative decoding enabled'); - } - - return details; - } - - /** - * Returns technical details without the progress message (for bottom bar) - */ - function getTechnicalDetails(): string[] { - const stateToUse = processingState || lastKnownState; - if (!stateToUse) { - return []; - } - - const details: string[] = []; - - // Always show context info when we have valid data - if ( - typeof stateToUse.contextTotal === 'number' && - stateToUse.contextUsed >= 0 && - stateToUse.contextTotal > 0 - ) { - const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100); - - details.push( - `Context: ${stateToUse.contextUsed}/${stateToUse.contextTotal} (${contextPercent}%)` - ); - } - - if (stateToUse.outputTokensUsed > 0) { - // Handle infinite max_tokens (-1) case - if (stateToUse.outputTokensMax <= 0) { - details.push(`Output: ${stateToUse.outputTokensUsed}/∞`); - } else { - const outputPercent = Math.round( - (stateToUse.outputTokensUsed / stateToUse.outputTokensMax) * 100 - ); - - details.push( - `Output: ${stateToUse.outputTokensUsed}/${stateToUse.outputTokensMax} (${outputPercent}%)` - ); - } - } - - if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) { - details.push(`${stateToUse.tokensPerSecond.toFixed(1)} ${STATS_UNITS.TOKENS_PER_SECOND}`); - } - - if (stateToUse.speculative) { - details.push('Speculative decoding enabled'); - } - - return details; - } - - function shouldShowDetails(): boolean { - return processingState !== null && processingState.status !== 'idle'; - } - - /** - * Returns a short progress message with percent - */ - function getPromptProgressText(): string | null { - if (!processingState?.promptProgress) return null; - - const { processed, total, cache } = processingState.promptProgress; - - const actualProcessed = processed - cache; - const actualTotal = total - cache; - const percent = Math.round((actualProcessed / actualTotal) * 100); - const eta = getETASecs(actualProcessed, actualTotal, processingState.promptProgress.time_ms); - - if (eta !== undefined) { - const etaSecs = Math.ceil(eta); - return `Processing ${percent}% (ETA: ${etaSecs}s)`; - } - - return `Processing ${percent}%`; - } - - /** - * Returns live processing statistics for display (prompt processing phase) - * Returns last known stats when promptProgress becomes unavailable - */ - function getLiveProcessingStats(): LiveProcessingStats | null { - if (processingState?.promptProgress) { - const { processed, total, time_ms, cache } = processingState.promptProgress; - - const actualProcessed = processed - cache; - const actualTotal = total - cache; - - if (actualProcessed > 0 && time_ms > 0) { - const tokensPerSecond = actualProcessed / (time_ms / 1000); - - return { - tokensProcessed: actualProcessed, - totalTokens: actualTotal, - timeMs: time_ms, - tokensPerSecond - }; - } - } - - // Return last known stats if promptProgress is no longer available - return lastKnownProcessingStats; - } - - /** - * Returns live generation statistics for display (token generation phase) - */ - function getLiveGenerationStats(): LiveGenerationStats | null { - if (!processingState) return null; - - const { tokensDecoded, tokensPerSecond } = processingState; - - if (tokensDecoded <= 0) return null; - - // Calculate time from tokens and speed - const timeMs = - tokensPerSecond && tokensPerSecond > 0 ? (tokensDecoded / tokensPerSecond) * 1000 : 0; - - return { - tokensGenerated: tokensDecoded, - timeMs, - tokensPerSecond: tokensPerSecond || 0 - }; - } - - return { - get processingState() { - return processingState; - }, - getProcessingDetails, - getTechnicalDetails, - getProcessingMessage, - getPromptProgressText, - getLiveProcessingStats, - getLiveGenerationStats, - shouldShowDetails, - startMonitoring, - stopMonitoring - }; -} diff --git a/tools/server/webui/src/lib/hooks/use-scroll-carousel.svelte.ts b/tools/server/webui/src/lib/hooks/use-scroll-carousel.svelte.ts deleted file mode 100644 index e4c75d236..000000000 --- a/tools/server/webui/src/lib/hooks/use-scroll-carousel.svelte.ts +++ /dev/null @@ -1,61 +0,0 @@ -export function useScrollCarousel() { - let canScrollLeft = $state(false); - let canScrollRight = $state(false); - let scrollContainer = $state(); - - function scrollToCenter(element: HTMLElement) { - if (!scrollContainer) return; - - const containerRect = scrollContainer.getBoundingClientRect(); - const elementRect = element.getBoundingClientRect(); - - const elementCenter = elementRect.left + elementRect.width / 2; - const containerCenter = containerRect.left + containerRect.width / 2; - const scrollOffset = elementCenter - containerCenter; - - scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' }); - } - - function scrollLeft() { - if (!scrollContainer) return; - scrollContainer.scrollBy({ left: -250, behavior: 'smooth' }); - } - - function scrollRight() { - if (!scrollContainer) return; - scrollContainer.scrollBy({ left: 250, behavior: 'smooth' }); - } - - function updateScrollButtons() { - if (!scrollContainer) return; - - const { scrollLeft: sl, scrollWidth, clientWidth } = scrollContainer; - canScrollLeft = sl > 0; - canScrollRight = sl < scrollWidth - clientWidth - 1; - } - - $effect(() => { - if (scrollContainer) { - updateScrollButtons(); - } - }); - - return { - get canScrollLeft() { - return canScrollLeft; - }, - get canScrollRight() { - return canScrollRight; - }, - get scrollContainer() { - return scrollContainer; - }, - set scrollContainer(el: HTMLDivElement | undefined) { - scrollContainer = el; - }, - scrollToCenter, - scrollLeft, - scrollRight, - updateScrollButtons - }; -} diff --git a/tools/server/webui/src/lib/hooks/use-settings-navigation.svelte.ts b/tools/server/webui/src/lib/hooks/use-settings-navigation.svelte.ts deleted file mode 100644 index 3cbcaaeda..000000000 --- a/tools/server/webui/src/lib/hooks/use-settings-navigation.svelte.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { page } from '$app/state'; -import { beforeNavigate } from '$app/navigation'; -import { settingsReferrer } from '$lib/stores/settings-referrer.svelte'; -import { ROUTES } from '$lib/constants/routes'; - -export interface ChatSettings { - reset: () => void; -} - -export function useSettingsNavigation() { - const subroute = $state({ - activePanel: 'chat' as 'chat' | 'settings' | 'mcp', - chatSettingsRef: undefined as ChatSettings | undefined - }); - - const isSettingsRoute = $derived(!!page.route.id?.startsWith('/settings')); - - beforeNavigate(({ to, from }) => { - if (to?.route?.id?.startsWith('/settings') && !from?.route?.id?.startsWith('/settings')) { - settingsReferrer.url = window.location.hash || ROUTES.START; - } - }); - - $effect(() => { - if (subroute.activePanel === 'settings' && subroute.chatSettingsRef) { - subroute.chatSettingsRef.reset(); - } - }); - - // Return to chat when navigating to a new route - $effect(() => { - void page.url; - - subroute.activePanel = 'chat'; - }); - - return { - get panel() { - return subroute; - }, - - get isSettingsRoute() { - return isSettingsRoute; - } - }; -} diff --git a/tools/server/webui/src/lib/hooks/use-tools-panel.svelte.ts b/tools/server/webui/src/lib/hooks/use-tools-panel.svelte.ts deleted file mode 100644 index 4df3c7a5f..000000000 --- a/tools/server/webui/src/lib/hooks/use-tools-panel.svelte.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { SvelteSet } from 'svelte/reactivity'; -import { ToolSource } from '$lib/enums'; -import { conversationsStore } from '$lib/stores/conversations.svelte'; -import { mcpStore } from '$lib/stores/mcp.svelte'; -import { toolsStore } from '$lib/stores/tools.svelte'; -import type { ToolGroup } from '$lib/types'; - -export interface UseToolsPanelReturn { - readonly expandedGroups: SvelteSet; - readonly groups: ToolGroup[]; - readonly activeGroups: ToolGroup[]; - readonly totalToolCount: number; - readonly noToolsInfoMessage: string | null; - getGroupCheckedState(group: ToolGroup): { checked: boolean; indeterminate: boolean }; - getEnabledToolCount(group: ToolGroup): number; - getFavicon(group: { source: ToolSource; label: string }): string | null; - isGroupDisabled(group: ToolGroup): boolean; - toggleGroupExpanded(label: string): void; - handleOpen(): void; -} - -/** - * Shared reactive state and helpers for the tools panel UI. - * - * Used by both the desktop dropdown (`ChatFormActionAddToolsSubmenu`) - * and the mobile sheet (`ChatFormActionAddSheet`) to avoid - * duplicating group filtering, checked-state derivation, and favicon logic. - */ -export function useToolsPanel(): UseToolsPanelReturn { - const expandedGroups = new SvelteSet(); - - const groups = $derived(toolsStore.toolGroups); - const activeGroups = $derived( - groups.filter( - (g) => - g.source !== ToolSource.MCP || - !g.serverId || - conversationsStore.isMcpServerEnabledForChat(g.serverId) - ) - ); - const totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0)); - const noToolsInfoMessage = $derived.by(() => { - if (toolsStore.loading) return null; - if (toolsStore.toolGroups.length > 0) return null; - // Tools endpoint is unreachable (404) — server started without --tools - if (toolsStore.isToolsEndpointUnreachable) { - return 'To enable Built-In Tools you need to run llama-server with --tools all or --tools flag. To see MCP Tools you need to add / enable MCP Server(s).'; - } - // Other errors — return null so UI shows "Failed to load tools" - if (toolsStore.error) return null; - return 'To enable Built-In Tools you need to run llama-server with --tools all or --tools flag. To see MCP Tools you need to add / enable MCP Server(s).'; - }); - - function getGroupCheckedState(group: ToolGroup): { checked: boolean; indeterminate: boolean } { - return { - checked: toolsStore.isGroupFullyEnabled(group), - indeterminate: toolsStore.isGroupPartiallyEnabled(group) - }; - } - - function getEnabledToolCount(group: ToolGroup): number { - return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.function.name)).length; - } - - function getFavicon(group: { source: ToolSource; label: string }): string | null { - if (group.source !== ToolSource.MCP) return null; - - for (const server of mcpStore.getServersSorted()) { - if (mcpStore.getServerLabel(server) === group.label) { - return mcpStore.getServerFavicon(server.id); - } - } - - return null; - } - - function isGroupDisabled(group: ToolGroup): boolean { - return ( - group.source === ToolSource.MCP && - !!group.serverId && - !conversationsStore.isMcpServerEnabledForChat(group.serverId) - ); - } - - function toggleGroupExpanded(label: string): void { - if (expandedGroups.has(label)) { - expandedGroups.delete(label); - } else { - expandedGroups.add(label); - } - } - - function handleOpen(): void { - if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) { - toolsStore.fetchBuiltinTools(); - } - mcpStore.runHealthChecksForServers(mcpStore.getServersSorted().filter((s) => s.enabled)); - } - - return { - expandedGroups, - get groups() { - return groups; - }, - get activeGroups() { - return activeGroups; - }, - get totalToolCount() { - return totalToolCount; - }, - get noToolsInfoMessage() { - return noToolsInfoMessage; - }, - getGroupCheckedState, - getEnabledToolCount, - getFavicon, - isGroupDisabled, - toggleGroupExpanded, - handleOpen - }; -} diff --git a/tools/server/webui/src/lib/services/chat.service.ts b/tools/server/webui/src/lib/services/chat.service.ts deleted file mode 100644 index 2cd521da0..000000000 --- a/tools/server/webui/src/lib/services/chat.service.ts +++ /dev/null @@ -1,1094 +0,0 @@ -import { getJsonHeaders } from '$lib/utils/api-headers'; -import { formatAttachmentText } from '$lib/utils/formatters'; -import { isAbortError } from '$lib/utils/abort'; -import { - ATTACHMENT_LABEL_PDF_FILE, - ATTACHMENT_LABEL_MCP_PROMPT, - ATTACHMENT_LABEL_MCP_RESOURCE, - LEGACY_AGENTIC_REGEX -} from '$lib/constants'; -import { - AttachmentType, - ContentPartType, - MessageRole, - ReasoningFormat, - UrlProtocol -} from '$lib/enums'; -import type { - ApiChatMessageContentPart, - ApiChatMessageData, - ApiChatCompletionToolCall -} from '$lib/types/api'; -import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } from '$lib/types'; -import { modelsStore } from '$lib/stores/models.svelte'; - -export class ChatService { - /** - * - * - * Title Generation - * - * - */ - - /** - * Sends a streaming chat completion request for generating a chat title. - * Delegates to `sendMessage` for fetch, SSE parsing, and error handling. - * - * @param message - The single message to send (a user message containing the title generation prompt) - * @param model - Optional model name to use (required in ROUTER mode) - * @param signal - Optional AbortSignal to cancel the request - * @returns {Promise} The aggregated title text, or empty string if request failed - * @static - */ - static async generateTitle( - message: ApiChatMessageData, - model?: string | null, - signal?: AbortSignal - ): Promise { - let titleResponse = ''; - try { - await ChatService.sendMessage( - [message], - { - model: model || undefined, - stream: true, - custom: { chat_template_kwargs: { enable_thinking: false } }, - onChunk: (chunk: string) => { - titleResponse += chunk; - } - }, - undefined, - signal - ); - } catch { - return ''; - } - return titleResponse; - } - - /** - * - * - * Messaging - * - * - */ - - /** - * Sends a chat completion request to the llama-server. - * Supports both streaming and non-streaming responses with comprehensive parameter configuration. - * Automatically converts database messages with attachments to the appropriate API format. - * - * @param messages - Array of chat messages to send to the API (supports both ApiChatMessageData and DatabaseMessage with attachments) - * @param options - Configuration options for the chat completion request. See `SettingsChatServiceOptions` type for details. - * @returns {Promise} that resolves to the complete response string (non-streaming) or void (streaming) - * @throws {Error} if the request fails or is aborted - */ - static async sendMessage( - messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[], - options: SettingsChatServiceOptions = {}, - conversationId?: string, - signal?: AbortSignal - ): Promise { - const { - stream, - onChunk, - onComplete, - onError, - onReasoningChunk, - onToolCallChunk, - onModel, - onTimings, - // Tools for function calling - tools, - // Generation parameters - temperature, - max_tokens, - // Sampling parameters - dynatemp_range, - dynatemp_exponent, - top_k, - top_p, - min_p, - xtc_probability, - xtc_threshold, - typ_p, - // Penalty parameters - repeat_last_n, - repeat_penalty, - presence_penalty, - frequency_penalty, - dry_multiplier, - dry_base, - dry_allowed_length, - dry_penalty_last_n, - // Other parameters - samplers, - backend_sampling, - custom, - timings_per_token, - // Config options - disableReasoningParsing, - excludeReasoningFromContext, - continueFinalMessage - } = options; - - const normalizedMessages: ApiChatMessageData[] = messages - .map((msg) => { - if ('id' in msg && 'convId' in msg && 'timestamp' in msg) { - const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }; - - return ChatService.convertDbMessageToApiChatMessageData(dbMsg); - } else { - return msg as ApiChatMessageData; - } - }) - .filter((msg) => { - // Filter out empty system messages - if (msg.role === MessageRole.SYSTEM) { - const content = typeof msg.content === 'string' ? msg.content : ''; - - return content.trim().length > 0; - } - - return true; - }); - - // Filter out image attachments if the model doesn't support vision - if (options.model && !modelsStore.modelSupportsVision(options.model)) { - normalizedMessages.forEach((msg) => { - if (Array.isArray(msg.content)) { - msg.content = msg.content.filter((part: ApiChatMessageContentPart) => { - if (part.type === ContentPartType.IMAGE_URL) { - console.info( - `[ChatService] Skipping image attachment in message history (model "${options.model}" does not support vision)` - ); - - return false; - } - - return true; - }); - // If only text remains and it's a single part, simplify to string - if ( - msg.content.length === 1 && - msg.content[0].type === ContentPartType.TEXT && - typeof msg.content[0].text === 'string' - ) { - msg.content = msg.content[0].text; - } - } - }); - } - - const requestBody: ApiChatCompletionRequest = { - messages: normalizedMessages.map((msg: ApiChatMessageData) => { - const mapped: ApiChatCompletionRequest['messages'][0] = { - role: msg.role, - content: msg.content, - tool_calls: msg.tool_calls, - tool_call_id: msg.tool_call_id - }; - // Include reasoning_content from the dedicated field - if (!excludeReasoningFromContext && msg.reasoning_content) { - mapped.reasoning_content = msg.reasoning_content; - } - return mapped; - }), - stream, - return_progress: stream ? true : undefined, - tools: tools && tools.length > 0 ? tools : undefined - }; - - // Include model in request if provided (required in ROUTER mode) - if (options.model) { - requestBody.model = options.model; - } - - requestBody.reasoning_format = disableReasoningParsing - ? ReasoningFormat.NONE - : ReasoningFormat.AUTO; - - if (continueFinalMessage) { - requestBody.continue_final_message = true; - requestBody.add_generation_prompt = false; - } - - if (temperature !== undefined) requestBody.temperature = temperature; - if (max_tokens !== undefined) { - // Set max_tokens to -1 (infinite) when explicitly configured as 0 or null - requestBody.max_tokens = max_tokens !== null && max_tokens !== 0 ? max_tokens : -1; - } - - if (dynatemp_range !== undefined) requestBody.dynatemp_range = dynatemp_range; - if (dynatemp_exponent !== undefined) requestBody.dynatemp_exponent = dynatemp_exponent; - if (top_k !== undefined) requestBody.top_k = top_k; - if (top_p !== undefined) requestBody.top_p = top_p; - if (min_p !== undefined) requestBody.min_p = min_p; - if (xtc_probability !== undefined) requestBody.xtc_probability = xtc_probability; - if (xtc_threshold !== undefined) requestBody.xtc_threshold = xtc_threshold; - if (typ_p !== undefined) requestBody.typ_p = typ_p; - - if (repeat_last_n !== undefined) requestBody.repeat_last_n = repeat_last_n; - if (repeat_penalty !== undefined) requestBody.repeat_penalty = repeat_penalty; - if (presence_penalty !== undefined) requestBody.presence_penalty = presence_penalty; - if (frequency_penalty !== undefined) requestBody.frequency_penalty = frequency_penalty; - if (dry_multiplier !== undefined) requestBody.dry_multiplier = dry_multiplier; - if (dry_base !== undefined) requestBody.dry_base = dry_base; - if (dry_allowed_length !== undefined) requestBody.dry_allowed_length = dry_allowed_length; - if (dry_penalty_last_n !== undefined) requestBody.dry_penalty_last_n = dry_penalty_last_n; - - if (samplers !== undefined) { - requestBody.samplers = - typeof samplers === 'string' - ? samplers.split(';').filter((s: string) => s.trim()) - : samplers; - } - - if (backend_sampling !== undefined) requestBody.backend_sampling = backend_sampling; - - if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token; - - if (custom) { - try { - const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom; - Object.assign(requestBody, customParams); - } catch (error) { - console.warn('Failed to parse custom parameters:', error); - } - } - - try { - const response = await fetch(`./v1/chat/completions`, { - method: 'POST', - headers: getJsonHeaders(), - body: JSON.stringify(requestBody), - signal - }); - - if (!response.ok) { - const error = await ChatService.parseErrorResponse(response); - - if (onError) { - onError(error); - } - - throw error; - } - - if (stream) { - await ChatService.handleStreamResponse( - response, - onChunk, - onComplete, - onError, - onReasoningChunk, - onToolCallChunk, - onModel, - onTimings, - conversationId, - signal - ); - - return; - } else { - return ChatService.handleNonStreamResponse( - response, - onComplete, - onError, - onToolCallChunk, - onModel - ); - } - } catch (error) { - if (isAbortError(error)) { - console.log('Chat completion request was aborted'); - return; - } - - let userFriendlyError: Error; - - if (error instanceof Error) { - if (error.name === 'TypeError' && error.message.includes('fetch')) { - userFriendlyError = new Error( - 'Unable to connect to server - please check if the server is running' - ); - userFriendlyError.name = 'NetworkError'; - } else if (error.message.includes('ECONNREFUSED')) { - userFriendlyError = new Error('Connection refused - server may be offline'); - userFriendlyError.name = 'NetworkError'; - } else if (error.message.includes('ETIMEDOUT')) { - userFriendlyError = new Error('Request timed out - the server took too long to respond'); - userFriendlyError.name = 'TimeoutError'; - } else { - userFriendlyError = error; - } - } else { - userFriendlyError = new Error('Unknown error occurred while sending message'); - } - - console.error('Error in sendMessage:', error); - - if (onError) { - onError(userFriendlyError); - } - - throw userFriendlyError; - } - } - - /** - * Checks whether all server slots are currently idle (not processing any requests). - * Queries the /slots endpoint (requires --slots flag on the server). - * Returns true if all slots are idle, false if any is processing. - * If the endpoint is unavailable or errors out, returns true (best-effort fallback). - * - * @param signal - Optional AbortSignal to cancel the request if needed - * @param model - Optional model name to check slots for (required in ROUTER mode) - * @returns {Promise} Promise that resolves to true if all slots are idle, false if any is processing - */ - static async areAllSlotsIdle(model?: string | null, signal?: AbortSignal): Promise { - try { - const url = model ? `./slots?model=${encodeURIComponent(model)}` : './slots'; - const res = await fetch(url, { signal }); - if (!res.ok) return true; - - const slots: { is_processing: boolean }[] = await res.json(); - return slots.every((s) => !s.is_processing); - } catch { - return true; - } - } - - /** - * Sends a fire-and-forget request to pre-encode the conversation in the server's KV cache. - * After a response completes, this re-submits the full conversation - * using n_predict=0 and stream=false so the server processes the prompt without generating tokens. - * This warms the cache for the next turn, making it faster. - * - * When excludeReasoningFromContext is true, reasoning content is stripped from the messages - * to match what sendMessage would send on the next turn (avoiding cache misses). - * When false, reasoning_content is preserved so the cached prompt matches the next request. - * - * @param messages - The full conversation including the latest assistant response - * @param model - Optional model name (required in ROUTER mode) - * @param excludeReasoning - Whether to strip reasoning content (should match excludeReasoningFromContext setting) - * @param signal - Optional AbortSignal to cancel the pre-encode request - */ - static async preEncode( - messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[], - model?: string | null, - excludeReasoning?: boolean, - signal?: AbortSignal - ): Promise { - const normalizedMessages: ApiChatMessageData[] = messages - .map((msg) => { - if ('id' in msg && 'convId' in msg && 'timestamp' in msg) { - return ChatService.convertDbMessageToApiChatMessageData( - msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] } - ); - } - - return msg as ApiChatMessageData; - }) - .filter((msg) => { - if (msg.role === MessageRole.SYSTEM) { - const content = typeof msg.content === 'string' ? msg.content : ''; - - return content.trim().length > 0; - } - - return true; - }); - - const requestBody: Record = { - messages: normalizedMessages.map((msg: ApiChatMessageData) => { - const mapped: Record = { - role: msg.role, - content: excludeReasoning ? ChatService.stripReasoningContent(msg.content) : msg.content, - tool_calls: msg.tool_calls, - tool_call_id: msg.tool_call_id - }; - - if (!excludeReasoning && msg.reasoning_content) { - mapped.reasoning_content = msg.reasoning_content; - } - - return mapped; - }), - stream: false, - n_predict: 0 - }; - - if (model) { - requestBody.model = model; - } - - try { - await fetch(`./v1/chat/completions`, { - method: 'POST', - headers: getJsonHeaders(), - body: JSON.stringify(requestBody), - signal - }); - } catch (error) { - if (!isAbortError(error)) { - console.warn('[ChatService] Pre-encode request failed:', error); - } - } - } - - /** - * - * - * Streaming - * - * - */ - - /** - * Handles streaming response from the chat completion API - * @param response - The Response object from the fetch request - * @param onChunk - Optional callback invoked for each content chunk received - * @param onComplete - Optional callback invoked when the stream is complete with full response - * @param onError - Optional callback invoked if an error occurs during streaming - * @param onReasoningChunk - Optional callback invoked for each reasoning content chunk - * @param conversationId - Optional conversation ID for per-conversation state tracking - * @returns {Promise} Promise that resolves when streaming is complete - * @throws {Error} if the stream cannot be read or parsed - */ - private static async handleStreamResponse( - response: Response, - onChunk?: (chunk: string) => void, - onComplete?: ( - response: string, - reasoningContent?: string, - timings?: ChatMessageTimings, - toolCalls?: string - ) => void, - onError?: (error: Error) => void, - onReasoningChunk?: (chunk: string) => void, - onToolCallChunk?: (chunk: string) => void, - onModel?: (model: string) => void, - onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void, - conversationId?: string, - abortSignal?: AbortSignal - ): Promise { - const reader = response.body?.getReader(); - - if (!reader) { - throw new Error('No response body'); - } - - const decoder = new TextDecoder(); - let aggregatedContent = ''; - let fullReasoningContent = ''; - let aggregatedToolCalls: ApiChatCompletionToolCall[] = []; - let lastTimings: ChatMessageTimings | undefined; - let streamFinished = false; - let modelEmitted = false; - let toolCallIndexOffset = 0; - let hasOpenToolCallBatch = false; - - const finalizeOpenToolCallBatch = () => { - if (!hasOpenToolCallBatch) { - return; - } - - toolCallIndexOffset = aggregatedToolCalls.length; - hasOpenToolCallBatch = false; - }; - - const processToolCallDelta = (toolCalls?: ApiChatCompletionToolCallDelta[]) => { - if (!toolCalls || toolCalls.length === 0) { - return; - } - - aggregatedToolCalls = ChatService.mergeToolCallDeltas( - aggregatedToolCalls, - toolCalls, - toolCallIndexOffset - ); - - if (aggregatedToolCalls.length === 0) { - return; - } - - hasOpenToolCallBatch = true; - - const serializedToolCalls = JSON.stringify(aggregatedToolCalls); - - if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { - console.log('[ChatService] Aggregated tool calls:', serializedToolCalls); - } - - if (!serializedToolCalls) { - return; - } - - if (!abortSignal?.aborted) { - onToolCallChunk?.(serializedToolCalls); - } - }; - - try { - let chunk = ''; - while (true) { - if (abortSignal?.aborted) break; - - const { done, value } = await reader.read(); - if (done) break; - - if (abortSignal?.aborted) break; - - chunk += decoder.decode(value, { stream: true }); - const lines = chunk.split('\n'); - chunk = lines.pop() || ''; - - for (const line of lines) { - if (abortSignal?.aborted) break; - - if (line.startsWith(UrlProtocol.DATA)) { - const data = line.slice(6); - if (data === '[DONE]') { - streamFinished = true; - - continue; - } - - try { - const parsed: ApiChatCompletionStreamChunk = JSON.parse(data); - const choice = parsed.choices?.[0]; - const content = choice?.delta?.content; - const reasoningContent = choice?.delta?.reasoning_content; - const toolCalls = choice?.delta?.tool_calls; - const timings = parsed.timings; - const promptProgress = parsed.prompt_progress; - - const chunkModel = ChatService.extractModelName(parsed); - if (chunkModel && !modelEmitted) { - modelEmitted = true; - onModel?.(chunkModel); - } - - if (promptProgress) { - ChatService.notifyTimings(undefined, promptProgress, onTimings); - } - - if (timings) { - ChatService.notifyTimings(timings, promptProgress, onTimings); - lastTimings = timings; - } - - if (content) { - finalizeOpenToolCallBatch(); - aggregatedContent += content; - if (!abortSignal?.aborted) { - onChunk?.(content); - } - } - - if (reasoningContent) { - finalizeOpenToolCallBatch(); - fullReasoningContent += reasoningContent; - if (!abortSignal?.aborted) { - onReasoningChunk?.(reasoningContent); - } - } - - processToolCallDelta(toolCalls); - } catch (e) { - console.error('Error parsing JSON chunk:', e); - } - } - } - - if (abortSignal?.aborted) break; - } - - if (abortSignal?.aborted) return; - - if (streamFinished) { - finalizeOpenToolCallBatch(); - - const finalToolCalls = - aggregatedToolCalls.length > 0 ? JSON.stringify(aggregatedToolCalls) : undefined; - - onComplete?.( - aggregatedContent, - fullReasoningContent || undefined, - lastTimings, - finalToolCalls - ); - } - } catch (error) { - const err = error instanceof Error ? error : new Error('Stream error'); - - onError?.(err); - - throw err; - } finally { - reader.releaseLock(); - } - } - - /** - * Handles non-streaming response from the chat completion API. - * Parses the JSON response and extracts the generated content. - * - * @param response - The fetch Response object containing the JSON data - * @param onComplete - Optional callback invoked when response is successfully parsed - * @param onError - Optional callback invoked if an error occurs during parsing - * @returns {Promise} Promise that resolves to the generated content string - * @throws {Error} if the response cannot be parsed or is malformed - */ - private static async handleNonStreamResponse( - response: Response, - onComplete?: ( - response: string, - reasoningContent?: string, - timings?: ChatMessageTimings, - toolCalls?: string - ) => void, - onError?: (error: Error) => void, - onToolCallChunk?: (chunk: string) => void, - onModel?: (model: string) => void - ): Promise { - try { - const responseText = await response.text(); - - if (!responseText.trim()) { - const noResponseError = new Error('No response received from server. Please try again.'); - - throw noResponseError; - } - - const data: ApiChatCompletionResponse = JSON.parse(responseText); - - const responseModel = ChatService.extractModelName(data); - if (responseModel) { - onModel?.(responseModel); - } - - const content = data.choices[0]?.message?.content || ''; - const reasoningContent = data.choices[0]?.message?.reasoning_content; - const toolCalls = data.choices[0]?.message?.tool_calls; - - let serializedToolCalls: string | undefined; - - if (toolCalls && toolCalls.length > 0) { - const mergedToolCalls = ChatService.mergeToolCallDeltas([], toolCalls); - - if (mergedToolCalls.length > 0) { - serializedToolCalls = JSON.stringify(mergedToolCalls); - if (serializedToolCalls) { - onToolCallChunk?.(serializedToolCalls); - } - } - } - - if (!content.trim() && !serializedToolCalls) { - const noResponseError = new Error('No response received from server. Please try again.'); - - throw noResponseError; - } - - onComplete?.(content, reasoningContent, undefined, serializedToolCalls); - - return content; - } catch (error) { - const err = error instanceof Error ? error : new Error('Parse error'); - - onError?.(err); - - throw err; - } - } - - /** - * Merges tool call deltas into an existing array of tool calls. - * Handles both existing and new tool calls, updating existing ones and adding new ones. - * - * @param existing - The existing array of tool calls to merge into - * @param deltas - The array of tool call deltas to merge - * @param indexOffset - Optional offset to apply to the index of new tool calls - * @returns {ApiChatCompletionToolCall[]} The merged array of tool calls - */ - private static mergeToolCallDeltas( - existing: ApiChatCompletionToolCall[], - deltas: ApiChatCompletionToolCallDelta[], - indexOffset = 0 - ): ApiChatCompletionToolCall[] { - const result = existing.map((call) => ({ - ...call, - function: call.function ? { ...call.function } : undefined - })); - - for (const delta of deltas) { - const index = - typeof delta.index === 'number' && delta.index >= 0 - ? delta.index + indexOffset - : result.length; - - while (result.length <= index) { - result.push({ function: undefined }); - } - - const target = result[index]!; - - if (delta.id) { - target.id = delta.id; - } - - if (delta.type) { - target.type = delta.type; - } - - if (delta.function) { - const fn = target.function ? { ...target.function } : {}; - - if (delta.function.name) { - fn.name = delta.function.name; - } - - if (delta.function.arguments) { - fn.arguments = (fn.arguments ?? '') + delta.function.arguments; - } - - target.function = fn; - } - } - - return result; - } - - /** - * - * - * Conversion - * - * - */ - - /** - * Converts a database message with attachments to API chat message format. - * Processes various attachment types (images, text files, PDFs) and formats them - * as content parts suitable for the chat completion API. - * - * @param message - Database message object with optional extra attachments - * @param message.content - The text content of the message - * @param message.role - The role of the message sender (user, assistant, system) - * @param message.extra - Optional array of message attachments (images, files, etc.) - * @returns {ApiChatMessageData} object formatted for the chat completion API - * @static - */ - static convertDbMessageToApiChatMessageData( - message: DatabaseMessage & { extra?: DatabaseMessageExtra[] } - ): ApiChatMessageData { - // Handle tool result messages (role: 'tool') - if (message.role === MessageRole.TOOL && message.toolCallId) { - return { - role: MessageRole.TOOL, - content: message.content, - tool_call_id: message.toolCallId - }; - } - - // Parse tool calls for assistant messages - let toolCalls: ApiChatCompletionToolCall[] | undefined; - if (message.toolCalls) { - try { - toolCalls = JSON.parse(message.toolCalls); - } catch { - // Ignore parse errors for malformed tool calls - } - } - - if (!message.extra || message.extra.length === 0) { - const result: ApiChatMessageData = { - role: message.role as MessageRole, - content: message.content - }; - - if (message.reasoningContent) { - result.reasoning_content = message.reasoningContent; - } - - if (toolCalls && toolCalls.length > 0) { - result.tool_calls = toolCalls; - } - - return result; - } - - const contentParts: ApiChatMessageContentPart[] = []; - - if (message.content) { - contentParts.push({ - type: ContentPartType.TEXT, - text: message.content - }); - } - - // Include images from all messages - const imageFiles = message.extra.filter( - (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile => - extra.type === AttachmentType.IMAGE - ); - - for (const image of imageFiles) { - contentParts.push({ - type: ContentPartType.IMAGE_URL, - image_url: { url: image.base64Url } - }); - } - - const textFiles = message.extra.filter( - (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile => - extra.type === AttachmentType.TEXT - ); - - for (const textFile of textFiles) { - contentParts.push({ - type: ContentPartType.TEXT, - text: formatAttachmentText('File', textFile.name, textFile.content) - }); - } - - // Handle legacy 'context' type from old webui (pasted content) - const legacyContextFiles = message.extra.filter( - (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraLegacyContext => - extra.type === AttachmentType.LEGACY_CONTEXT - ); - - for (const legacyContextFile of legacyContextFiles) { - contentParts.push({ - type: ContentPartType.TEXT, - text: formatAttachmentText('File', legacyContextFile.name, legacyContextFile.content) - }); - } - - const audioFiles = message.extra.filter( - (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraAudioFile => - extra.type === AttachmentType.AUDIO - ); - - for (const audio of audioFiles) { - contentParts.push({ - type: ContentPartType.INPUT_AUDIO, - input_audio: { - data: audio.base64Data, - format: audio.mimeType.includes('wav') ? 'wav' : 'mp3' - } - }); - } - - const pdfFiles = message.extra.filter( - (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile => - extra.type === AttachmentType.PDF - ); - - for (const pdfFile of pdfFiles) { - if (pdfFile.processedAsImages && pdfFile.images) { - for (let i = 0; i < pdfFile.images.length; i++) { - contentParts.push({ - type: ContentPartType.IMAGE_URL, - image_url: { url: pdfFile.images[i] } - }); - } - } else { - contentParts.push({ - type: ContentPartType.TEXT, - text: formatAttachmentText(ATTACHMENT_LABEL_PDF_FILE, pdfFile.name, pdfFile.content) - }); - } - } - - const mcpPrompts = message.extra.filter( - (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpPrompt => - extra.type === AttachmentType.MCP_PROMPT - ); - - for (const mcpPrompt of mcpPrompts) { - contentParts.push({ - type: ContentPartType.TEXT, - text: formatAttachmentText( - ATTACHMENT_LABEL_MCP_PROMPT, - mcpPrompt.name, - mcpPrompt.content, - mcpPrompt.serverName - ) - }); - } - - const mcpResources = message.extra.filter( - (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpResource => - extra.type === AttachmentType.MCP_RESOURCE - ); - - for (const mcpResource of mcpResources) { - contentParts.push({ - type: ContentPartType.TEXT, - text: formatAttachmentText( - ATTACHMENT_LABEL_MCP_RESOURCE, - mcpResource.name, - mcpResource.content, - mcpResource.serverName - ) - }); - } - - const result: ApiChatMessageData = { - role: message.role as MessageRole, - content: contentParts - }; - if (message.reasoningContent) { - result.reasoning_content = message.reasoningContent; - } - if (toolCalls && toolCalls.length > 0) { - result.tool_calls = toolCalls; - } - return result; - } - - /** - * - * - * Utilities - * - * - */ - - /** - * Strips legacy inline reasoning content tags from message content. - * Handles both plain string content and multipart content arrays. - */ - private static stripReasoningContent( - content: string | ApiChatMessageContentPart[] - ): string | ApiChatMessageContentPart[] { - const stripFromString = (text: string): string => - text.replace(LEGACY_AGENTIC_REGEX.REASONING_BLOCK, '').trim(); - - if (typeof content === 'string') { - return stripFromString(content); - } - - return content.map((part) => { - if (part.type === ContentPartType.TEXT && part.text) { - return { ...part, text: stripFromString(part.text) }; - } - return part; - }); - } - - /** - * Parses error response and creates appropriate error with context information - * @param response - HTTP response object - * @returns Promise - Parsed error with context info if available - */ - private static async parseErrorResponse( - response: Response - ): Promise { - try { - const errorText = await response.text(); - const errorData: ApiErrorResponse = JSON.parse(errorText); - - const message = errorData.error?.message || 'Unknown server error'; - const error = new Error(message) as Error & { - contextInfo?: { n_prompt_tokens: number; n_ctx: number }; - }; - error.name = response.status === 400 ? 'ServerError' : 'HttpError'; - - if (errorData.error && 'n_prompt_tokens' in errorData.error && 'n_ctx' in errorData.error) { - error.contextInfo = { - n_prompt_tokens: errorData.error.n_prompt_tokens, - n_ctx: errorData.error.n_ctx - }; - } - - return error; - } catch { - const fallback = new Error( - `Server error (${response.status}): ${response.statusText}` - ) as Error & { - contextInfo?: { n_prompt_tokens: number; n_ctx: number }; - }; - fallback.name = 'HttpError'; - - return fallback; - } - } - - /** - * Extracts model name from Chat Completions API response data. - * Handles various response formats including streaming chunks and final responses. - * - * WORKAROUND: In single model mode, llama-server returns a default/incorrect model name - * in the response. We override it with the actual model name from serverStore. - * - * @param data - Raw response data from the Chat Completions API - * @returns Model name string if found, undefined otherwise - * @private - */ - private static extractModelName(data: unknown): string | undefined { - const asRecord = (value: unknown): Record | undefined => { - return typeof value === 'object' && value !== null - ? (value as Record) - : undefined; - }; - - const getTrimmedString = (value: unknown): string | undefined => { - return typeof value === 'string' && value.trim() ? value.trim() : undefined; - }; - - const root = asRecord(data); - if (!root) return undefined; - - // 1) root (some implementations provide `model` at the top level) - const rootModel = getTrimmedString(root.model); - if (rootModel) { - return rootModel; - } - - // 2) streaming choice (delta) or final response (message) - const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined; - if (!firstChoice) { - return undefined; - } - - // priority: delta.model (first chunk) else message.model (final response) - const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model); - if (deltaModel) { - return deltaModel; - } - - const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model); - if (messageModel) { - return messageModel; - } - - // avoid guessing from non-standard locations (metadata, etc.) - return undefined; - } - - /** - * Calls the onTimings callback with timing data from streaming response. - * - * @param timings - Timing information from the Chat Completions API response - * @param promptProgress - Prompt processing progress data - * @param onTimingsCallback - Callback function to invoke with timing data - * @private - */ - private static notifyTimings( - timings: ChatMessageTimings | undefined, - promptProgress: ChatMessagePromptProgress | undefined, - onTimingsCallback: - | ((timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void) - | undefined - ): void { - if (!onTimingsCallback || (!timings && !promptProgress)) return; - - onTimingsCallback(timings, promptProgress); - } -} diff --git a/tools/server/webui/src/lib/services/database.service.ts b/tools/server/webui/src/lib/services/database.service.ts deleted file mode 100644 index 8f7b81fe9..000000000 --- a/tools/server/webui/src/lib/services/database.service.ts +++ /dev/null @@ -1,491 +0,0 @@ -import Dexie, { type EntityTable } from 'dexie'; -import { findDescendantMessages, uuid, filterByLeafNodeId } from '$lib/utils'; -import type { McpServerOverride } from '$lib/types/database'; - -class LlamacppDatabase extends Dexie { - conversations!: EntityTable; - messages!: EntityTable; - - constructor() { - super('LlamacppWebui'); - - this.version(1).stores({ - conversations: 'id, lastModified, currNode, name', - messages: 'id, convId, type, role, timestamp, parent, children' - }); - } -} - -const db = new LlamacppDatabase(); -import { MessageRole } from '$lib/enums'; - -export class DatabaseService { - /** - * - * - * Conversations - * - * - */ - - /** - * Creates a new conversation. - * - * @param name - Name of the conversation - * @returns The created conversation - */ - static async createConversation(name: string): Promise { - const conversation: DatabaseConversation = { - id: uuid(), - name, - lastModified: Date.now(), - currNode: '' - }; - - await db.conversations.add(conversation); - return conversation; - } - - /** - * - * - * Messages - * - * - */ - - /** - * Creates a new message branch by adding a message and updating parent/child relationships. - * Also updates the conversation's currNode to point to the new message. - * - * @param message - Message to add (without id) - * @param parentId - Parent message ID to attach to - * @returns The created message - */ - static async createMessageBranch( - message: Omit, - parentId: string | null - ): Promise { - return await db.transaction('rw', [db.conversations, db.messages], async () => { - // Handle null parent (root message case) - if (parentId !== null) { - const parentMessage = await db.messages.get(parentId); - if (!parentMessage) { - throw new Error(`Parent message ${parentId} not found`); - } - } - - const newMessage: DatabaseMessage = { - ...message, - id: uuid(), - parent: parentId, - toolCalls: message.toolCalls ?? '', - children: [] - }; - - await db.messages.add(newMessage); - - // Update parent's children array if parent exists - if (parentId !== null) { - const parentMessage = await db.messages.get(parentId); - if (parentMessage) { - await db.messages.update(parentId, { - children: [...parentMessage.children, newMessage.id] - }); - } - } - - await this.updateConversation(message.convId, { - currNode: newMessage.id - }); - - return newMessage; - }); - } - - /** - * Creates a root message for a new conversation. - * Root messages are not displayed but serve as the tree root for branching. - * - * @param convId - Conversation ID - * @returns The created root message - */ - static async createRootMessage(convId: string): Promise { - const rootMessage: DatabaseMessage = { - id: uuid(), - convId, - type: 'root', - timestamp: Date.now(), - role: MessageRole.SYSTEM, - content: '', - parent: null, - toolCalls: '', - children: [] - }; - - await db.messages.add(rootMessage); - return rootMessage.id; - } - - /** - * Creates a system prompt message for a conversation. - * - * @param convId - Conversation ID - * @param systemPrompt - The system prompt content (must be non-empty) - * @param parentId - Parent message ID (typically the root message) - * @returns The created system message - * @throws Error if systemPrompt is empty - */ - static async createSystemMessage( - convId: string, - systemPrompt: string, - parentId: string - ): Promise { - const trimmedPrompt = systemPrompt.trim(); - if (!trimmedPrompt) { - throw new Error('Cannot create system message with empty content'); - } - - const systemMessage: DatabaseMessage = { - id: uuid(), - convId, - type: MessageRole.SYSTEM, - timestamp: Date.now(), - role: MessageRole.SYSTEM, - content: trimmedPrompt, - parent: parentId, - children: [] - }; - - await db.messages.add(systemMessage); - - const parentMessage = await db.messages.get(parentId); - if (parentMessage) { - await db.messages.update(parentId, { - children: [...parentMessage.children, systemMessage.id] - }); - } - - return systemMessage; - } - - /** - * Deletes a conversation and all its messages. - * - * @param id - Conversation ID - */ - static async deleteConversation( - id: string, - options?: { deleteWithForks?: boolean } - ): Promise { - await db.transaction('rw', [db.conversations, db.messages], async () => { - if (options?.deleteWithForks) { - // Recursively collect all descendant IDs - const idsToDelete: string[] = []; - const queue = [id]; - - while (queue.length > 0) { - const parentId = queue.pop()!; - const children = await db.conversations - .filter((c) => c.forkedFromConversationId === parentId) - .toArray(); - - for (const child of children) { - idsToDelete.push(child.id); - queue.push(child.id); - } - } - - for (const forkId of idsToDelete) { - await db.conversations.delete(forkId); - await db.messages.where('convId').equals(forkId).delete(); - } - } else { - // Reparent direct children to deleted conv's parent - const conv = await db.conversations.get(id); - const newParent = conv?.forkedFromConversationId; - const directChildren = await db.conversations - .filter((c) => c.forkedFromConversationId === id) - .toArray(); - - for (const child of directChildren) { - await db.conversations.update(child.id, { - forkedFromConversationId: newParent ?? undefined - }); - } - } - - await db.conversations.delete(id); - await db.messages.where('convId').equals(id).delete(); - }); - } - - /** - * Deletes a message and removes it from its parent's children array. - * - * @param messageId - ID of the message to delete - */ - static async deleteMessage(messageId: string): Promise { - await db.transaction('rw', db.messages, async () => { - const message = await db.messages.get(messageId); - if (!message) return; - - // Remove this message from its parent's children array - if (message.parent) { - const parent = await db.messages.get(message.parent); - if (parent) { - parent.children = parent.children.filter((childId: string) => childId !== messageId); - await db.messages.put(parent); - } - } - - // Delete the message - await db.messages.delete(messageId); - }); - } - - /** - * Deletes a message and all its descendant messages (cascading deletion). - * This removes the entire branch starting from the specified message. - * - * @param conversationId - ID of the conversation containing the message - * @param messageId - ID of the root message to delete (along with all descendants) - * @returns Array of all deleted message IDs - */ - static async deleteMessageCascading( - conversationId: string, - messageId: string - ): Promise { - return await db.transaction('rw', db.messages, async () => { - // Get all messages in the conversation to find descendants - const allMessages = await db.messages.where('convId').equals(conversationId).toArray(); - - // Find all descendant messages - const descendants = findDescendantMessages(allMessages, messageId); - const allToDelete = [messageId, ...descendants]; - - // Get the message to delete for parent cleanup - const message = await db.messages.get(messageId); - if (message && message.parent) { - const parent = await db.messages.get(message.parent); - if (parent) { - parent.children = parent.children.filter((childId: string) => childId !== messageId); - await db.messages.put(parent); - } - } - - // Delete all messages in the branch - await db.messages.bulkDelete(allToDelete); - - return allToDelete; - }); - } - - /** - * Gets all conversations, sorted by last modified time (newest first). - * - * @returns Array of conversations - */ - static async getAllConversations(): Promise { - return await db.conversations.orderBy('lastModified').reverse().toArray(); - } - - /** - * Gets a conversation by ID. - * - * @param id - Conversation ID - * @returns The conversation if found, otherwise undefined - */ - static async getConversation(id: string): Promise { - return await db.conversations.get(id); - } - - /** - * Gets all messages in a conversation, sorted by timestamp (oldest first). - * - * @param convId - Conversation ID - * @returns Array of messages in the conversation - */ - static async getConversationMessages(convId: string): Promise { - return await db.messages.where('convId').equals(convId).sortBy('timestamp'); - } - - /** - * Updates a conversation. - * - * @param id - Conversation ID - * @param updates - Partial updates to apply - * @returns Promise that resolves when the conversation is updated - */ - static async updateConversation( - id: string, - updates: Partial> - ): Promise { - await db.conversations.update(id, { - ...updates, - lastModified: Date.now() - }); - } - - /** - * - * - * Navigation - * - * - */ - - /** - * Updates the conversation's current node (active branch). - * This determines which conversation path is currently being viewed. - * - * @param convId - Conversation ID - * @param nodeId - Message ID to set as current node - */ - static async updateCurrentNode(convId: string, nodeId: string): Promise { - await this.updateConversation(convId, { - currNode: nodeId - }); - } - - /** - * Updates a message. - * - * @param id - Message ID - * @param updates - Partial updates to apply - * @returns Promise that resolves when the message is updated - */ - static async updateMessage( - id: string, - updates: Partial> - ): Promise { - await db.messages.update(id, updates); - } - - /** - * - * - * Import - * - * - */ - - /** - * Imports multiple conversations and their messages. - * Skips conversations that already exist. - * - * @param data - Array of { conv, messages } objects - */ - static async importConversations( - data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[] - ): Promise<{ imported: number; skipped: number }> { - let importedCount = 0; - let skippedCount = 0; - - return await db.transaction('rw', [db.conversations, db.messages], async () => { - for (const item of data) { - const { conv, messages } = item; - - const existing = await db.conversations.get(conv.id); - if (existing) { - console.warn(`Conversation "${conv.name}" already exists, skipping...`); - skippedCount++; - continue; - } - - await db.conversations.add(conv); - for (const msg of messages) { - await db.messages.put(msg); - } - - importedCount++; - } - - return { imported: importedCount, skipped: skippedCount }; - }); - } - - /** - * - * - * Forking - * - * - */ - - /** - * Forks a conversation at a specific message, creating a new conversation - * containing all messages from the root up to (and including) the target message. - * - * @param sourceConvId - The source conversation ID - * @param atMessageId - The message ID to fork at (the new conversation ends here) - * @param options - Fork options (name and whether to include attachments) - * @returns The newly created conversation - */ - static async forkConversation( - sourceConvId: string, - atMessageId: string, - options: { name: string; includeAttachments: boolean } - ): Promise { - return await db.transaction('rw', [db.conversations, db.messages], async () => { - const sourceConv = await db.conversations.get(sourceConvId); - if (!sourceConv) { - throw new Error(`Source conversation ${sourceConvId} not found`); - } - - const allMessages = await db.messages.where('convId').equals(sourceConvId).toArray(); - - const pathMessages = filterByLeafNodeId(allMessages, atMessageId, true) as DatabaseMessage[]; - if (pathMessages.length === 0) { - throw new Error(`Could not resolve message path to ${atMessageId}`); - } - - const idMap = new Map(); - - for (const msg of pathMessages) { - idMap.set(msg.id, uuid()); - } - - const newConvId = uuid(); - const clonedMessages: DatabaseMessage[] = pathMessages.map((msg) => { - const newId = idMap.get(msg.id)!; - const newParent = msg.parent ? (idMap.get(msg.parent) ?? null) : null; - const newChildren = msg.children - .filter((childId: string) => idMap.has(childId)) - .map((childId: string) => idMap.get(childId)!); - - return { - ...msg, - id: newId, - convId: newConvId, - parent: newParent, - children: newChildren, - extra: options.includeAttachments ? msg.extra : undefined - }; - }); - - const lastClonedMessage = clonedMessages[clonedMessages.length - 1]; - const newConv: DatabaseConversation = { - id: newConvId, - name: options.name, - lastModified: Date.now(), - currNode: lastClonedMessage.id, - forkedFromConversationId: sourceConvId, - mcpServerOverrides: sourceConv.mcpServerOverrides - ? sourceConv.mcpServerOverrides.map((o: McpServerOverride) => ({ - serverId: o.serverId, - enabled: o.enabled - })) - : undefined - }; - - await db.conversations.add(newConv); - - for (const msg of clonedMessages) { - await db.messages.add(msg); - } - - return newConv; - }); - } -} diff --git a/tools/server/webui/src/lib/services/index.ts b/tools/server/webui/src/lib/services/index.ts deleted file mode 100644 index edfcd6b81..000000000 --- a/tools/server/webui/src/lib/services/index.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * - * SERVICES - * - * Stateless service layer for API communication and data operations. - * Services handle protocol-level concerns (HTTP, WebSocket, MCP, IndexedDB) - * without managing reactive state — that responsibility belongs to stores. - * - * **Design Principles:** - * - All methods are static — no instance state - * - Pure I/O operations (network requests, database queries) - * - No Svelte runes or reactive primitives - * - Error handling at the protocol level; business-level error handling in stores - * - * **Architecture (bottom to top):** - * - **Services** (this layer): Stateless protocol communication - * - **Stores**: Reactive state management consuming services - * - **Components**: UI consuming stores - * - */ - -/** - * **ChatService** - Chat Completions API communication layer - * - * Handles direct communication with the llama-server's `/v1/chat/completions` endpoint. - * Provides streaming and non-streaming response parsing, message format conversion - * (DatabaseMessage → API format), and request lifecycle management. - * - * **Terminology - Chat vs Conversation:** - * - **Chat**: The active interaction space with the Chat Completions API. Ephemeral and - * runtime-focused — sending messages, receiving streaming responses, managing request lifecycles. - * - **Conversation**: The persistent database entity storing all messages and metadata. - * Managed by conversationsStore, conversations persist across sessions. - * - * **Architecture & Relationships:** - * - **ChatService** (this class): Stateless API communication layer - * - Handles HTTP requests/responses with the llama-server - * - Manages streaming and non-streaming response parsing - * - Converts database messages to API format (multimodal, tool calls) - * - Handles error translation with user-friendly messages - * - * - **chatStore**: Primary consumer — uses ChatService for all AI model communication - * - **agenticStore**: Uses ChatService for multi-turn agentic loop streaming - * - **conversationsStore**: Provides message context for API requests - * - * **Key Responsibilities:** - * - Streaming response handling with real-time content/reasoning/tool-call callbacks - * - Non-streaming response parsing with complete response extraction - * - Database message to API format conversion (attachments, tool calls, multimodal) - * - Tool call delta merging for incremental streaming aggregation - * - Request parameter assembly (sampling, penalties, custom params) - * - File attachment processing (images, PDFs, audio, text, MCP prompts/resources) - * - Reasoning content stripping from prompt history to avoid KV cache pollution - * - Error translation (network, timeout, server errors → user-friendly messages) - * - * @see chatStore in stores/chat.svelte.ts — primary consumer for chat state management - * @see agenticStore in stores/agentic.svelte.ts — uses ChatService for agentic loop streaming - * @see conversationsStore in stores/conversations.svelte.ts — provides message context - */ -export { ChatService } from './chat.service'; - -/** - * **DatabaseService** - IndexedDB persistence layer via Dexie ORM - * - * Provides stateless data access for conversations and messages using IndexedDB. - * Handles all low-level storage operations including branching tree structures, - * cascade deletions, and transaction safety for multi-table operations. - * - * **Architecture & Relationships (bottom to top):** - * - **DatabaseService** (this class): Stateless IndexedDB operations - * - Lowest layer — direct Dexie/IndexedDB communication - * - Pure CRUD operations without business logic - * - Handles branching tree structure (parent-child relationships) - * - Provides transaction safety for multi-table operations - * - * - **conversationsStore**: Reactive state management layer - * - Uses DatabaseService for all persistence operations - * - Manages conversation list, active conversation, and messages in memory - * - * - **chatStore**: Active AI interaction management - * - Uses conversationsStore for conversation context - * - Directly uses DatabaseService for message CRUD during streaming - * - * **Key Responsibilities:** - * - Conversation CRUD (create, read, update, delete) - * - Message CRUD with branching support (parent-child relationships) - * - Root message and system prompt creation - * - Cascade deletion of message branches (descendants) - * - Transaction-safe multi-table operations - * - Conversation import with duplicate detection - * - * **Database Schema:** - * - `conversations`: id, lastModified, currNode, name - * - `messages`: id, convId, type, role, timestamp, parent, children - * - * **Branching Model:** - * Messages form a tree structure where each message can have multiple children, - * enabling conversation branching and alternative response paths. The conversation's - * `currNode` tracks the currently active branch endpoint. - * - * @see conversationsStore in stores/conversations.svelte.ts — reactive layer on top of DatabaseService - * @see chatStore in stores/chat.svelte.ts — uses DatabaseService directly for message CRUD during streaming - */ -export { DatabaseService } from './database.service'; - -/** - * **ModelsService** - Model management API communication - * - * Handles communication with model-related endpoints for both MODEL (single model) - * and ROUTER (multi-model) server modes. Provides model listing, loading/unloading, - * and status checking without managing any model state. - * - * **Architecture & Relationships:** - * - **ModelsService** (this class): Stateless HTTP communication - * - Sends requests to model endpoints - * - Parses and returns typed API responses - * - Provides model status utility methods - * - * - **modelsStore**: Primary consumer — manages reactive model state - * - Calls ModelsService for all model API operations - * - Handles polling, caching, and state updates - * - * **Key Responsibilities:** - * - List available models via OpenAI-compatible `/v1/models` endpoint - * - Load/unload models via `/models/load` and `/models/unload` (ROUTER mode) - * - Model status queries (loaded, loading) - * - * **Server Mode Behavior:** - * - **MODEL mode**: Only `list()` is relevant — single model always loaded - * - **ROUTER mode**: Full lifecycle — `list()`, `listRouter()`, `load()`, `unload()` - * - * **Endpoints:** - * - `GET /v1/models` — OpenAI-compatible model list (both modes) - * - `POST /models/load` — Load a model (ROUTER mode only) - * - `POST /models/unload` — Unload a model (ROUTER mode only) - * - * @see modelsStore in stores/models.svelte.ts — primary consumer for reactive model state - */ -export { ModelsService } from './models.service'; - -/** - * **PropsService** - Server properties and capabilities retrieval - * - * Fetches server configuration, model information, and capabilities from the `/props` - * endpoint. Supports both global server props and per-model props (ROUTER mode). - * - * **Architecture & Relationships:** - * - **PropsService** (this class): Stateless HTTP communication - * - Fetches server properties from `/props` endpoint - * - Handles authentication and request parameters - * - Returns typed `ApiLlamaCppServerProps` responses - * - * - **serverStore**: Consumes global server properties (role detection, connection state) - * - **modelsStore**: Consumes per-model properties (modalities, context size) - * - **settingsStore**: Syncs default generation parameters from props response - * - * **Key Responsibilities:** - * - Fetch global server properties (default generation settings, modalities) - * - Fetch per-model properties in ROUTER mode via `?model=` parameter - * - Handle autoload control to prevent unintended model loading - * - * **API Behavior:** - * - `GET /props` → Global server props (MODEL mode: includes modalities) - * - `GET /props?model=` → Per-model props (ROUTER mode: model-specific modalities) - * - `&autoload=false` → Prevents model auto-loading when querying props - * - * @see serverStore in stores/server.svelte.ts — consumes global server props - * @see modelsStore in stores/models.svelte.ts — consumes per-model props for modalities - * @see settingsStore in stores/settings.svelte.ts — syncs default generation params from props - */ -export { PropsService } from './props.service'; - -/** - * **ParameterSyncService** - Server defaults and user settings synchronization - * - * Manages the complex logic of merging server-provided default parameters with - * user-configured overrides. Ensures the UI reflects the actual server state - * while preserving user customizations. Tracks parameter sources (server default - * vs user override) for display in the settings UI. - * - * **Architecture & Relationships:** - * - **ParameterSyncService** (this class): Stateless sync logic - * - Pure functions for parameter extraction, merging, and diffing - * - No side effects — receives data in, returns data out - * - Handles floating-point precision normalization - * - * - **settingsStore**: Primary consumer — calls sync methods during: - * - Initial load (`syncWithServerDefaults`) - * - Settings reset (`forceSyncWithServerDefaults`) - * - Parameter info queries (`getParameterInfo`) - * - * - **PropsService**: Provides raw server props that feed into extraction - * - * **Key Responsibilities:** - * - Extract syncable parameters from server `/props` response - * - Merge server defaults with user overrides (user wins) - * - Track parameter source (Custom vs Default) for UI badges - * - Validate server parameter values by type (number, string, boolean) - * - Create diffs between current settings and server defaults - * - Floating-point precision normalization for consistent comparisons - * - * **Parameter Source Priority:** - * 1. **User Override** (Custom badge) — explicitly set by user in settings - * 2. **Server Default** (Default badge) — from `/props` endpoint - * 3. **App Default** — hardcoded fallback when server props unavailable - * - * **Exports:** - * - `ParameterSyncService` class — static methods for sync logic - * - `SYNCABLE_PARAMETERS` — mapping of webui setting keys to server parameter keys - * - * @see settingsStore in stores/settings.svelte.ts — primary consumer for settings sync - * @see SettingsChatParameterSourceIndicator — displays parameter source badges in UI - */ -export { ParameterSyncService } from './parameter-sync.service'; - -/** - * **MCPService** - Low-level MCP protocol communication layer - * - * Implements the client-side MCP (Model Context Protocol) SDK operations for connecting - * to MCP servers, discovering capabilities, and executing protocol operations. - * Supports multiple transport types: WebSocket, StreamableHTTP, and SSE (legacy fallback). - * - * **Architecture & Relationships:** - * - **MCPService** (this class): Stateless protocol communication - * - Creates and manages transport connections (WebSocket, StreamableHTTP, SSE) - * - Wraps MCP SDK client operations with error handling - * - Formats tool results and extracts server info - * - Provides abort signal support for cancellable operations - * - * - **mcpStore**: Reactive business logic facade - * - Uses MCPService for all protocol-level operations - * - Manages connection lifecycle, health checks, reconnection - * - Handles tool name conflict resolution and server coordination - * - * - **mcpResourceStore**: Reactive resource state - * - Receives resource data fetched via MCPService - * - Manages resource caching, subscriptions, and attachments - * - * - **agenticStore**: Agentic loop orchestration - * - Executes tool calls via mcpStore → MCPService chain - * - * **Key Responsibilities:** - * - Transport creation with automatic fallback (StreamableHTTP → SSE) - * - Server connection with detailed phase tracking and progress callbacks - * - Tool discovery (`listTools`) and execution (`callTool`) with abort support - * - Prompt listing (`listPrompts`) and retrieval (`getPrompt`) with arguments - * - Resource operations: list, read, subscribe/unsubscribe, template support - * - Completion suggestions for prompt arguments and resource URI templates - * - CORS proxy routing via llama-server for cross-origin MCP servers - * - Tool result formatting (text, images, embedded resources) - * - * **Transport Hierarchy:** - * 1. **WebSocket** — bidirectional, no CORS proxy support - * 2. **StreamableHTTP** — modern HTTP-based, supports CORS proxy - * 3. **SSE** — legacy fallback, supports CORS proxy - * - * @see mcpStore in stores/mcp.svelte.ts — reactive business logic facade on top of MCPService - * @see mcpResourceStore in stores/mcp-resources.svelte.ts — reactive resource state management - * @see agenticStore in stores/agentic.svelte.ts — uses MCPService (via mcpStore) for tool execution - * @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18 - */ -export { MCPService } from './mcp.service'; - -/** - * **RouterService** — Dynamic route URL construction utility - * - * Stateless utility for building dynamic route URLs from ROUTES base paths. - * Static routes (START, NEW_CHAT, MCP_SERVERS) live in ROUTES constants; - * dynamic routes (CHAT, SETTINGS) are constructed here by appending parameters. - * - * **Architecture & Relationships:** - * - **RouterService** (this class): Stateless URL construction - * - Builds dynamic route URLs from ROUTES base paths - * - No side effects — receives route parameters, returns route strings - * - * - **ROUTES constant** (constants/routes.ts): Static route base paths - * - **All components/stores**: Call RouterService for dynamic route URLs - * - * **Key Responsibilities:** - * - Build chat URLs for specific conversations: `RouterService.chat(id)` → `#/chat/:id` - * - Build settings URLs for sections: `RouterService.settings(section)` → `#/settings/:section` - * - * @see ROUTES in constants/routes.ts — static route base paths - */ -export { RouterService } from './router.service'; diff --git a/tools/server/webui/src/lib/services/mcp.service.ts b/tools/server/webui/src/lib/services/mcp.service.ts deleted file mode 100644 index 458013b5a..000000000 --- a/tools/server/webui/src/lib/services/mcp.service.ts +++ /dev/null @@ -1,1110 +0,0 @@ -import { Client } from '@modelcontextprotocol/sdk/client'; -import { - StreamableHTTPClientTransport, - StreamableHTTPError -} from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; -import type { - Tool, - Prompt, - GetPromptResult, - ListChangedHandlers -} from '@modelcontextprotocol/sdk/types.js'; -import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; -import { - DEFAULT_MCP_CONFIG, - DEFAULT_CLIENT_VERSION, - DEFAULT_IMAGE_MIME_TYPE, - MCP_PARTIAL_REDACT_HEADERS -} from '$lib/constants'; -import { - MCPConnectionPhase, - MCPLogLevel, - MCPTransportType, - MCPContentType, - MCPRefType -} from '$lib/enums'; -import type { - MCPServerConfig, - MCPResourceIcon, - ToolCallParams, - ToolExecutionResult, - Implementation, - ClientCapabilities, - MCPConnection, - MCPPhaseCallback, - MCPConnectionLog, - MCPServerInfo, - MCPResource, - MCPResourceTemplate, - MCPResourceContent, - MCPReadResourceResult -} from '$lib/types'; -import { - buildProxiedUrl, - buildProxiedHeaders, - getAuthHeaders, - sanitizeHeaders, - throwIfAborted, - isAbortError, - createBase64DataUrl, - getRequestUrl, - getRequestMethod, - getRequestBody, - summarizeRequestBody, - formatDiagnosticErrorMessage, - extractJsonRpcMethods, - type RequestBodySummary -} from '$lib/utils'; - -interface ToolResultContentItem { - type: string; - text?: string; - data?: string; - mimeType?: string; - resource?: { text?: string; blob?: string; uri?: string }; -} - -interface ToolCallResult { - content?: ToolResultContentItem[]; - isError?: boolean; - _meta?: Record; -} - -interface DiagnosticRequestDetails { - url: string; - method: string; - credentials?: RequestCredentials; - mode?: RequestMode; - headers: Record; - body: RequestBodySummary; - jsonRpcMethods?: string[]; -} - -export class MCPService { - /** - * Create a connection log entry for phase tracking. - * - * @param phase - The connection phase this log belongs to - * @param message - Human-readable log message - * @param level - Log severity level (default: INFO) - * @param details - Optional structured details for debugging - * @returns Formatted connection log entry - */ - private static createLog( - phase: MCPConnectionPhase, - message: string, - level: MCPLogLevel = MCPLogLevel.INFO, - details?: unknown - ): MCPConnectionLog { - return { - timestamp: new Date(), - phase, - message, - level, - details - }; - } - - private static createDiagnosticRequestDetails( - input: RequestInfo | URL, - init: RequestInit | undefined, - baseInit: RequestInit, - requestHeaders: Headers, - extraRedactedHeaders?: Iterable - ): DiagnosticRequestDetails { - const body = getRequestBody(input, init); - const details: DiagnosticRequestDetails = { - url: getRequestUrl(input), - method: getRequestMethod(input, init, baseInit).toUpperCase(), - credentials: init?.credentials ?? baseInit.credentials, - mode: init?.mode ?? baseInit.mode, - headers: sanitizeHeaders(requestHeaders, extraRedactedHeaders, MCP_PARTIAL_REDACT_HEADERS), - body: summarizeRequestBody(body) - }; - const jsonRpcMethods = extractJsonRpcMethods(body); - - if (jsonRpcMethods) { - details.jsonRpcMethods = jsonRpcMethods; - } - - return details; - } - - private static summarizeError(error: unknown): Record { - if (error instanceof Error) { - return { - name: error.name, - message: error.message, - cause: - error.cause instanceof Error - ? { name: error.cause.name, message: error.cause.message } - : error.cause, - stack: error.stack?.split('\n').slice(0, 6).join('\n') - }; - } - - return { value: String(error) }; - } - - private static getBrowserContext( - targetUrl: URL, - useProxy: boolean - ): Record | undefined { - if (typeof window === 'undefined') { - return undefined; - } - - return { - location: window.location.href, - origin: window.location.origin, - protocol: window.location.protocol, - isSecureContext: window.isSecureContext, - targetOrigin: targetUrl.origin, - targetProtocol: targetUrl.protocol, - sameOrigin: window.location.origin === targetUrl.origin, - useProxy - }; - } - - private static getConnectionHints( - targetUrl: URL, - config: MCPServerConfig, - error: unknown - ): string[] { - const hints: string[] = []; - const message = error instanceof Error ? error.message : String(error); - const headerNames = Object.keys(config.headers ?? {}); - - if (typeof window !== 'undefined') { - if ( - window.location.protocol === 'https:' && - targetUrl.protocol === 'http:' && - !config.useProxy - ) { - hints.push( - 'The page is running over HTTPS but the MCP server is HTTP. Browsers often block this as mixed content; enable the proxy or use HTTPS/WSS for the MCP server.' - ); - } - - if (window.location.origin !== targetUrl.origin && !config.useProxy) { - hints.push( - 'This is a cross-origin browser request. If the server is reachable from curl or Node but not from the browser, missing CORS headers are the most likely cause.' - ); - } - } - - if (headerNames.length > 0) { - hints.push( - `Custom request headers are configured (${headerNames.join(', ')}). That triggers a CORS preflight, so the server must allow OPTIONS and include the matching Access-Control-Allow-Headers response.` - ); - } - - if (config.credentials && config.credentials !== 'omit') { - hints.push( - 'Credentials are enabled for this connection. Cross-origin credentialed requests need Access-Control-Allow-Credentials: true and cannot use a wildcard Access-Control-Allow-Origin.' - ); - } - - if (message.includes('Failed to fetch')) { - hints.push( - '"Failed to fetch" is a browser-level network failure. Common causes are CORS rejection, mixed-content blocking, certificate/TLS errors, DNS failures, or nothing listening on the target port.' - ); - } - - return hints; - } - - private static createDiagnosticFetch( - serverName: string, - config: MCPServerConfig, - baseInit: RequestInit, - targetUrl: URL, - useProxy: boolean, - onLog?: (log: MCPConnectionLog) => void - ): { - fetch: typeof fetch; - disable: () => void; - } { - let enabled = true; - const logIfEnabled = (log: MCPConnectionLog) => { - if (enabled) { - onLog?.(log); - } - }; - - return { - fetch: async (input, init) => { - const startedAt = performance.now(); - const requestHeaders = new Headers(baseInit.headers); - - if (typeof Request !== 'undefined' && input instanceof Request) { - for (const [key, value] of input.headers.entries()) { - requestHeaders.set(key, value); - } - } - - if (init?.headers) { - for (const [key, value] of new Headers(init.headers).entries()) { - requestHeaders.set(key, value); - } - } - - const request = this.createDiagnosticRequestDetails( - input, - init, - baseInit, - requestHeaders, - Object.keys(config.headers ?? {}) - ); - const { method, url } = request; - - logIfEnabled( - this.createLog( - MCPConnectionPhase.INITIALIZING, - `HTTP ${method} ${url}`, - MCPLogLevel.INFO, - { - serverName, - request - } - ) - ); - - try { - const response = await fetch(input, { - ...baseInit, - ...init, - headers: requestHeaders - }); - const durationMs = Math.round(performance.now() - startedAt); - - logIfEnabled( - this.createLog( - MCPConnectionPhase.INITIALIZING, - `HTTP ${response.status} ${method} ${url} (${durationMs}ms)`, - response.ok ? MCPLogLevel.INFO : MCPLogLevel.WARN, - { - response: { - url, - status: response.status, - statusText: response.statusText, - headers: sanitizeHeaders(response.headers, undefined, MCP_PARTIAL_REDACT_HEADERS), - durationMs - } - } - ) - ); - - return response; - } catch (error) { - const durationMs = Math.round(performance.now() - startedAt); - - logIfEnabled( - this.createLog( - MCPConnectionPhase.ERROR, - `HTTP ${method} ${url} failed: ${formatDiagnosticErrorMessage(error)}`, - MCPLogLevel.ERROR, - { - serverName, - request, - error: this.summarizeError(error), - browser: this.getBrowserContext(targetUrl, useProxy), - hints: this.getConnectionHints(targetUrl, config, error), - durationMs - } - ) - ); - - throw error; - } - }, - disable: () => { - enabled = false; - } - }; - } - - /** - * Detect if an error indicates an expired/invalidated MCP session. - * Per MCP spec 2025-11-25: HTTP 404 means session invalidated, client MUST - * discard its session ID and start a new session with a fresh initialize request. - * - * @param error - The caught error to inspect - * @returns true if the error is a StreamableHTTP 404 (session not found) - */ - static isSessionExpiredError(error: unknown): boolean { - return error instanceof StreamableHTTPError && error.code === 404; - } - - /** - * Create transport based on server configuration. - * Supports WebSocket, StreamableHTTP (modern), and SSE (legacy) transports. - * When `useProxy` is enabled, routes HTTP requests through llama-server's CORS proxy. - * - * **Fallback Order:** - * 1. WebSocket — if explicitly configured (no CORS proxy support) - * 2. StreamableHTTP — default for HTTP connections - * 3. SSE — automatic fallback if StreamableHTTP fails - * - * @param config - Server configuration with url, transport type, proxy, and auth settings - * @returns Object containing the created transport and the transport type used - * @throws {Error} If url is missing, WebSocket + proxy combination, or all transports fail - */ - static createTransport( - serverName: string, - config: MCPServerConfig, - onLog?: (log: MCPConnectionLog) => void - ): { - transport: Transport; - type: MCPTransportType; - stopPhaseLogging: () => void; - } { - if (!config.url) { - throw new Error('MCP server configuration is missing url'); - } - - const useProxy = config.useProxy ?? false; - const requestInit: RequestInit = {}; - - if (config.headers) { - requestInit.headers = config.useProxy ? buildProxiedHeaders(config.headers) : config.headers; - } - - if (useProxy) { - requestInit.headers = { - ...getAuthHeaders(), - ...(requestInit.headers as Record) - }; - } - - if (config.credentials) { - requestInit.credentials = config.credentials; - } - - if (config.transport === MCPTransportType.WEBSOCKET) { - if (useProxy) { - throw new Error( - 'WebSocket transport is not supported when using CORS proxy. Use HTTP transport instead.' - ); - } - - const url = new URL(config.url); - - if (import.meta.env.DEV) { - console.log(`[MCPService] Creating WebSocket transport for ${url.href}`); - } - - return { - transport: new WebSocketClientTransport(url), - type: MCPTransportType.WEBSOCKET, - stopPhaseLogging: () => {} - }; - } - - const url = useProxy ? buildProxiedUrl(config.url) : new URL(config.url); - const { fetch: diagnosticFetch, disable: stopPhaseLogging } = this.createDiagnosticFetch( - serverName, - config, - requestInit, - url, - useProxy, - onLog - ); - - if (useProxy && import.meta.env.DEV) { - console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`); - } - - try { - if (import.meta.env.DEV) { - console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`); - } - - return { - transport: new StreamableHTTPClientTransport(url, { - requestInit, - fetch: diagnosticFetch - }), - type: MCPTransportType.STREAMABLE_HTTP, - stopPhaseLogging - }; - } catch (httpError) { - console.warn(`[MCPService] StreamableHTTP failed, trying SSE transport...`, httpError); - - try { - return { - transport: new SSEClientTransport(url, { - requestInit, - fetch: diagnosticFetch, - eventSourceInit: { fetch: diagnosticFetch } - }), - type: MCPTransportType.SSE, - stopPhaseLogging - }; - } catch (sseError) { - const httpMsg = httpError instanceof Error ? httpError.message : String(httpError); - const sseMsg = sseError instanceof Error ? sseError.message : String(sseError); - - throw new Error(`Failed to create transport. StreamableHTTP: ${httpMsg}; SSE: ${sseMsg}`); - } - } - } - - /** - * Extract server info from SDK Implementation type. - * Normalizes the SDK's server version response into our MCPServerInfo type. - * - * @param impl - Raw Implementation object from MCP SDK - * @returns Normalized server info or undefined if input is empty - */ - private static extractServerInfo(impl: Implementation | undefined): MCPServerInfo | undefined { - if (!impl) { - return undefined; - } - - return { - name: impl.name, - version: impl.version, - title: impl.title, - description: impl.description, - websiteUrl: impl.websiteUrl, - icons: impl.icons?.map((icon: MCPResourceIcon) => ({ - src: icon.src, - mimeType: icon.mimeType, - sizes: icon.sizes, - theme: icon.theme - })) - }; - } - - /** - * Connect to a single MCP server with detailed phase tracking. - * - * Performs the full MCP connection lifecycle: - * 1. Transport creation (with automatic fallback) - * 2. Client initialization and capability exchange - * 3. Tool discovery via `listTools` - * - * Reports progress via `onPhase` callback at each step, enabling - * UI progress indicators during connection. - * - * @param serverName - Display name for the server (used in logging) - * @param serverConfig - Server URL, transport type, proxy, and auth configuration - * @param clientInfo - Optional client identification (defaults to app info) - * @param capabilities - Optional client capability declaration - * @param onPhase - Optional callback for connection phase progress updates - * @param listChangedHandlers - Optional handlers for server-initiated list change notifications - * @returns Full connection object with client, transport, tools, server info, and timing - * @throws {Error} If transport creation or connection fails - */ - static async connect( - serverName: string, - serverConfig: MCPServerConfig, - clientInfo?: Implementation, - capabilities?: ClientCapabilities, - onPhase?: MCPPhaseCallback, - listChangedHandlers?: ListChangedHandlers - ): Promise { - const startTime = performance.now(); - const effectiveClientInfo = clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo; - const effectiveCapabilities = capabilities ?? DEFAULT_MCP_CONFIG.capabilities; - - // Phase: Creating transport - onPhase?.( - MCPConnectionPhase.TRANSPORT_CREATING, - this.createLog( - MCPConnectionPhase.TRANSPORT_CREATING, - `Creating transport for ${serverConfig.url}` - ) - ); - - if (import.meta.env.DEV) { - console.log(`[MCPService][${serverName}] Creating transport...`); - } - - const { - transport, - type: transportType, - stopPhaseLogging - } = this.createTransport(serverName, serverConfig, (log) => onPhase?.(log.phase, log)); - - // Setup WebSocket reconnection handler - if (transportType === MCPTransportType.WEBSOCKET) { - transport.onclose = () => { - console.log(`[MCPService][${serverName}] WebSocket closed, notifying for reconnection`); - onPhase?.( - MCPConnectionPhase.DISCONNECTED, - this.createLog(MCPConnectionPhase.DISCONNECTED, 'WebSocket connection closed') - ); - }; - } - - // Phase: Transport ready - onPhase?.( - MCPConnectionPhase.TRANSPORT_READY, - this.createLog(MCPConnectionPhase.TRANSPORT_READY, `Transport ready (${transportType})`), - { transportType } - ); - - const client = new Client( - { - name: effectiveClientInfo.name, - version: effectiveClientInfo.version ?? DEFAULT_CLIENT_VERSION - }, - { - capabilities: effectiveCapabilities, - listChanged: listChangedHandlers - } - ); - - const runtimeErrorHandler = (error: Error) => { - console.error(`[MCPService][${serverName}] Protocol error after initialize:`, error); - }; - - client.onerror = (error) => { - onPhase?.( - MCPConnectionPhase.ERROR, - this.createLog( - MCPConnectionPhase.ERROR, - `Protocol error: ${error.message}`, - MCPLogLevel.ERROR, - { - error: this.summarizeError(error) - } - ) - ); - }; - - // Phase: Initializing - onPhase?.( - MCPConnectionPhase.INITIALIZING, - this.createLog(MCPConnectionPhase.INITIALIZING, 'Sending initialize request...') - ); - - try { - await client.connect(transport); - // Transport diagnostics are only for the initial handshake, not long-lived traffic. - stopPhaseLogging(); - client.onerror = runtimeErrorHandler; - } catch (error) { - client.onerror = runtimeErrorHandler; - const url = - (serverConfig.useProxy ?? false) - ? buildProxiedUrl(serverConfig.url) - : new URL(serverConfig.url); - - onPhase?.( - MCPConnectionPhase.ERROR, - this.createLog( - MCPConnectionPhase.ERROR, - `Connection failed during initialize: ${ - error instanceof Error ? error.message : String(error) - }`, - MCPLogLevel.ERROR, - { - error: this.summarizeError(error), - config: { - serverName, - configuredUrl: serverConfig.url, - effectiveUrl: url.href, - transportType, - useProxy: serverConfig.useProxy ?? false, - headers: sanitizeHeaders( - serverConfig.headers, - Object.keys(serverConfig.headers ?? {}), - MCP_PARTIAL_REDACT_HEADERS - ), - credentials: serverConfig.credentials - }, - browser: this.getBrowserContext(url, serverConfig.useProxy ?? false), - hints: this.getConnectionHints(url, serverConfig, error) - } - ) - ); - - throw error; - } - - const serverVersion = client.getServerVersion(); - const serverCapabilities = client.getServerCapabilities(); - const instructions = client.getInstructions(); - const serverInfo = this.extractServerInfo(serverVersion); - - // Phase: Capabilities exchanged - onPhase?.( - MCPConnectionPhase.CAPABILITIES_EXCHANGED, - this.createLog( - MCPConnectionPhase.CAPABILITIES_EXCHANGED, - 'Capabilities exchanged successfully', - MCPLogLevel.INFO, - { - serverCapabilities, - serverInfo - } - ), - { - serverInfo, - serverCapabilities, - clientCapabilities: effectiveCapabilities, - instructions - } - ); - - // Phase: Listing tools - onPhase?.( - MCPConnectionPhase.LISTING_TOOLS, - this.createLog(MCPConnectionPhase.LISTING_TOOLS, 'Listing available tools...') - ); - - console.log(`[MCPService][${serverName}] Connected, listing tools...`); - const tools = await this.listTools({ - client, - transport, - tools: [], - serverName, - transportType, - connectionTimeMs: 0 - }); - - const connectionTimeMs = Math.round(performance.now() - startTime); - - // Phase: Connected - onPhase?.( - MCPConnectionPhase.CONNECTED, - this.createLog( - MCPConnectionPhase.CONNECTED, - `Connection established with ${tools.length} tools (${connectionTimeMs}ms)` - ) - ); - - console.log( - `[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms` - ); - - return { - client, - transport, - tools, - serverName, - transportType, - serverInfo, - serverCapabilities, - clientCapabilities: effectiveCapabilities, - protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion, - instructions, - connectionTimeMs - }; - } - - /** - * Disconnect from a server. - * Clears the `onclose` handler to prevent reconnection attempts on voluntary disconnect. - * - * @param connection - The active MCP connection to close - */ - static async disconnect(connection: MCPConnection): Promise { - console.log(`[MCPService][${connection.serverName}] Disconnecting...`); - try { - // Prevent reconnection on voluntary disconnect - if (connection.transport.onclose) { - connection.transport.onclose = undefined; - } - - await connection.client.close(); - } catch (error) { - console.warn(`[MCPService][${connection.serverName}] Error during disconnect:`, error); - } - } - - /** - * List tools from a connection. - * Silently returns empty array on failure (logged as warning). - * - * @param connection - The MCP connection to query - * @returns Array of available tools, or empty array on error - */ - static async listTools(connection: MCPConnection): Promise { - try { - const result = await connection.client.listTools(); - - return result.tools ?? []; - } catch (error) { - // Let session-expired errors propagate for reconnection handling - if (this.isSessionExpiredError(error)) { - throw error; - } - - console.warn(`[MCPService][${connection.serverName}] Failed to list tools:`, error); - - return []; - } - } - - /** - * List prompts from a connection. - * Silently returns empty array on failure (logged as warning). - * - * @param connection - The MCP connection to query - * @returns Array of available prompts, or empty array on error - */ - static async listPrompts(connection: MCPConnection): Promise { - try { - const result = await connection.client.listPrompts(); - - return result.prompts ?? []; - } catch (error) { - // Let session-expired errors propagate for reconnection handling - if (this.isSessionExpiredError(error)) { - throw error; - } - - console.warn(`[MCPService][${connection.serverName}] Failed to list prompts:`, error); - - return []; - } - } - - /** - * Get a specific prompt with arguments. - * Unlike list operations, this throws on failure since the caller explicitly - * requested a specific prompt and needs to handle the error. - * - * @param connection - The MCP connection to use - * @param name - The prompt name to retrieve - * @param args - Optional key-value arguments to pass to the prompt - * @returns The prompt result with messages and metadata - * @throws {Error} If the prompt retrieval fails - */ - static async getPrompt( - connection: MCPConnection, - name: string, - args?: Record - ): Promise { - try { - return await connection.client.getPrompt({ name, arguments: args }); - } catch (error) { - console.error(`[MCPService][${connection.serverName}] Failed to get prompt:`, error); - - throw error; - } - } - - /** - * Execute a tool call on a connection. - * Supports abort signal for cancellable operations (e.g., when user stops generation). - * Formats the raw tool result into a string representation. - * - * @param connection - The MCP connection to execute against - * @param params - Tool name and arguments to execute - * @param signal - Optional AbortSignal for cancellation support - * @returns Formatted tool execution result with content string and error flag - * @throws {Error} If tool execution fails or is aborted - */ - static async callTool( - connection: MCPConnection, - params: ToolCallParams, - signal?: AbortSignal - ): Promise { - throwIfAborted(signal); - - try { - const result = await connection.client.callTool( - { name: params.name, arguments: params.arguments }, - undefined, - { signal } - ); - - return { - content: this.formatToolResult(result as ToolCallResult), - isError: (result as ToolCallResult).isError ?? false - }; - } catch (error) { - if (isAbortError(error)) { - throw error; - } - - // Let session-expired errors propagate unwrapped for reconnection handling - if (this.isSessionExpiredError(error)) { - throw error; - } - - const message = error instanceof Error ? error.message : String(error); - - throw new Error( - `Tool "${params.name}" execution failed on server "${connection.serverName}": ${message}`, - { cause: error instanceof Error ? error : undefined } - ); - } - } - - /** - * Format tool result content items to a single string. - * Handles text, image (base64 data URL), and embedded resource content types. - * - * @param result - Raw tool call result from MCP SDK - * @returns Concatenated string representation of all content items - */ - private static formatToolResult(result: ToolCallResult): string { - const content = result.content; - if (!Array.isArray(content)) return ''; - - return content - .map((item) => this.formatSingleContent(item)) - .filter(Boolean) - .join('\n'); - } - - private static formatSingleContent(content: ToolResultContentItem): string { - if (content.type === MCPContentType.TEXT && content.text) { - return content.text; - } - - if (content.type === MCPContentType.IMAGE && content.data) { - return createBase64DataUrl(content.mimeType ?? DEFAULT_IMAGE_MIME_TYPE, content.data); - } - - if (content.type === MCPContentType.RESOURCE && content.resource) { - const resource = content.resource; - - if (resource.text) return resource.text; - if (resource.blob) return resource.blob; - - return JSON.stringify(resource); - } - - if (content.data && content.mimeType) { - return createBase64DataUrl(content.mimeType, content.data); - } - - return JSON.stringify(content); - } - - /** - * - * - * Completions Operations - * - * - */ - - /** - * Request completion suggestions from a server. - * Used for autocompleting prompt arguments or resource URI templates. - * - * @param connection - The MCP connection to use - * @param ref - Reference to the prompt or resource template - * @param argument - The argument being completed (name and current value) - * @returns Completion result with suggested values - */ - static async complete( - connection: MCPConnection, - ref: { type: MCPRefType.PROMPT; name: string } | { type: MCPRefType.RESOURCE; uri: string }, - argument: { name: string; value: string } - ): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> { - try { - const result = await connection.client.complete({ - ref, - argument - }); - - return result.completion; - } catch (error) { - console.error(`[MCPService] Failed to get completions:`, error); - - return null; - } - } - - /** - * - * - * Resources Operations - * - * - */ - - /** - * List resources from a connection. - * @param connection - The MCP connection to use - * @param cursor - Optional pagination cursor - * @returns Array of available resources and optional next cursor - */ - static async listResources( - connection: MCPConnection, - cursor?: string - ): Promise<{ resources: MCPResource[]; nextCursor?: string }> { - try { - const result = await connection.client.listResources(cursor ? { cursor } : undefined); - - return { - resources: (result.resources ?? []) as MCPResource[], - nextCursor: result.nextCursor - }; - } catch (error) { - if (this.isSessionExpiredError(error)) { - throw error; - } - - console.warn(`[MCPService][${connection.serverName}] Failed to list resources:`, error); - - return { resources: [] }; - } - } - - /** - * List all resources from a connection (handles pagination automatically). - * @param connection - The MCP connection to use - * @returns Array of all available resources - */ - static async listAllResources(connection: MCPConnection): Promise { - const allResources: MCPResource[] = []; - let cursor: string | undefined; - - do { - const result = await this.listResources(connection, cursor); - allResources.push(...result.resources); - cursor = result.nextCursor; - } while (cursor); - - return allResources; - } - - /** - * List resource templates from a connection. - * @param connection - The MCP connection to use - * @param cursor - Optional pagination cursor - * @returns Array of available resource templates and optional next cursor - */ - static async listResourceTemplates( - connection: MCPConnection, - cursor?: string - ): Promise<{ resourceTemplates: MCPResourceTemplate[]; nextCursor?: string }> { - try { - const result = await connection.client.listResourceTemplates(cursor ? { cursor } : undefined); - - return { - resourceTemplates: (result.resourceTemplates ?? []) as MCPResourceTemplate[], - nextCursor: result.nextCursor - }; - } catch (error) { - if (this.isSessionExpiredError(error)) { - throw error; - } - - console.warn( - `[MCPService][${connection.serverName}] Failed to list resource templates:`, - error - ); - - return { resourceTemplates: [] }; - } - } - - /** - * List all resource templates from a connection (handles pagination automatically). - * @param connection - The MCP connection to use - * @returns Array of all available resource templates - */ - static async listAllResourceTemplates(connection: MCPConnection): Promise { - const allTemplates: MCPResourceTemplate[] = []; - let cursor: string | undefined; - - do { - const result = await this.listResourceTemplates(connection, cursor); - allTemplates.push(...result.resourceTemplates); - cursor = result.nextCursor; - } while (cursor); - - return allTemplates; - } - - /** - * Read the contents of a resource. - * @param connection - The MCP connection to use - * @param uri - The URI of the resource to read - * @returns The resource contents - */ - static async readResource( - connection: MCPConnection, - uri: string - ): Promise { - try { - const result = await connection.client.readResource({ uri }); - - return { - contents: (result.contents ?? []) as MCPResourceContent[], - _meta: result._meta - }; - } catch (error) { - console.error(`[MCPService][${connection.serverName}] Failed to read resource:`, error); - - throw error; - } - } - - /** - * Subscribe to updates for a resource. - * The server will send notifications/resources/updated when the resource changes. - * @param connection - The MCP connection to use - * @param uri - The URI of the resource to subscribe to - */ - static async subscribeResource(connection: MCPConnection, uri: string): Promise { - try { - await connection.client.subscribeResource({ uri }); - - console.log(`[MCPService][${connection.serverName}] Subscribed to resource: ${uri}`); - } catch (error) { - console.error( - `[MCPService][${connection.serverName}] Failed to subscribe to resource:`, - error - ); - - throw error; - } - } - - /** - * Unsubscribe from updates for a resource. - * @param connection - The MCP connection to use - * @param uri - The URI of the resource to unsubscribe from - */ - static async unsubscribeResource(connection: MCPConnection, uri: string): Promise { - try { - await connection.client.unsubscribeResource({ uri }); - - console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`); - } catch (error) { - console.error( - `[MCPService][${connection.serverName}] Failed to unsubscribe from resource:`, - error - ); - - throw error; - } - } - - /** - * Check if a connection supports resources. - * Per MCP spec: presence of the `resources` key (even as empty object `{}`) indicates support. - * Empty object means resources are supported but no sub-features (subscribe, listChanged). - * - * @param connection - The MCP connection to check - * @returns Whether the server declares the resources capability - */ - static supportsResources(connection: MCPConnection): boolean { - // Per MCP spec: "Servers that support resources MUST declare the resources capability" - // The presence of the key indicates support, even if it's an empty object - return connection.serverCapabilities?.resources !== undefined; - } - - /** - * Check if a connection supports resource subscriptions. - * @param connection - The MCP connection to check - * @returns Whether the server supports resource subscriptions - */ - static supportsResourceSubscriptions(connection: MCPConnection): boolean { - return !!connection.serverCapabilities?.resources?.subscribe; - } -} diff --git a/tools/server/webui/src/lib/services/models.service.ts b/tools/server/webui/src/lib/services/models.service.ts deleted file mode 100644 index 209bd7cab..000000000 --- a/tools/server/webui/src/lib/services/models.service.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { ServerModelStatus } from '$lib/enums'; -import { apiFetch, apiPost } from '$lib/utils'; -import type { ParsedModelId } from '$lib/types/models'; -import { - MODEL_QUANTIZATION_SEGMENT_RE, - MODEL_CUSTOM_QUANTIZATION_PREFIX_RE, - MODEL_PARAMS_RE, - MODEL_ACTIVATED_PARAMS_RE, - MODEL_IGNORED_SEGMENTS, - MODEL_ID_NOT_FOUND, - MODEL_ID_ORG_SEPARATOR, - MODEL_ID_SEGMENT_SEPARATOR, - MODEL_ID_QUANTIZATION_SEPARATOR, - API_MODELS -} from '$lib/constants'; - -export class ModelsService { - /** - * - * - * Listing - * - * - */ - - /** - * Fetch list of models from OpenAI-compatible endpoint. - * Works in both MODEL and ROUTER modes. - * - * @returns List of available models with basic metadata - */ - static async list(): Promise { - return apiFetch(API_MODELS.LIST); - } - - /** - * Fetch list of all models with detailed metadata (ROUTER mode). - * Returns models with load status, paths, and other metadata - * beyond what the OpenAI-compatible endpoint provides. - * - * @returns List of models with detailed status and configuration info - */ - static async listRouter(): Promise { - return apiFetch(API_MODELS.LIST); - } - - /** - * - * - * Load/Unload - * - * - */ - - /** - * Load a model (ROUTER mode only). - * Sends POST request to `/models/load`. Note: the endpoint returns success - * before loading completes — use polling to await actual load status. - * - * @param modelId - Model identifier to load - * @param extraArgs - Optional additional arguments to pass to the model instance - * @returns Load response from the server - */ - static async load(modelId: string, extraArgs?: string[]): Promise { - const payload: { model: string; extra_args?: string[] } = { model: modelId }; - if (extraArgs && extraArgs.length > 0) { - payload.extra_args = extraArgs; - } - - return apiPost(API_MODELS.LOAD, payload); - } - - /** - * Unload a model (ROUTER mode only). - * Sends POST request to `/models/unload`. Note: the endpoint returns success - * before unloading completes — use polling to await actual unload status. - * - * @param modelId - Model identifier to unload - * @returns Unload response from the server - */ - static async unload(modelId: string): Promise { - return apiPost(API_MODELS.UNLOAD, { model: modelId }); - } - - /** - * - * - * Status - * - * - */ - - /** - * Check if a model is loaded based on its metadata. - * - * @param model - Model data entry from the API response - * @returns True if the model status is LOADED - */ - static isModelLoaded(model: ApiModelDataEntry): boolean { - return model.status.value === ServerModelStatus.LOADED; - } - - /** - * Check if a model is currently loading. - * - * @param model - Model data entry from the API response - * @returns True if the model status is LOADING - */ - static isModelLoading(model: ApiModelDataEntry): boolean { - return model.status.value === ServerModelStatus.LOADING; - } - - /** - * - * - * Parsing - * - * - */ - - /** - * Parse a model ID string into its structured components. - * - * Handles conventions like: - * `/-(-)(-)(-):` - * `.` (dot-separated quantization, e.g. `model.Q4_K_M`) - * - * @param modelId - Raw model identifier string - * @returns Structured {@link ParsedModelId} with all detected fields - */ - static parseModelId(modelId: string): ParsedModelId { - const result: ParsedModelId = { - raw: modelId, - orgName: null, - modelName: null, - params: null, - activatedParams: null, - quantization: null, - tags: [] - }; - - // 1. Extract colon-separated quantization (e.g. `model:Q4_K_M`) - const colonIdx = modelId.indexOf(MODEL_ID_QUANTIZATION_SEPARATOR); - let modelPath: string; - - if (colonIdx !== MODEL_ID_NOT_FOUND) { - result.quantization = modelId.slice(colonIdx + 1) || null; - modelPath = modelId.slice(0, colonIdx); - } else { - modelPath = modelId; - } - - // 2. Extract org name (e.g. `org/model` -> org = "org") - const slashIdx = modelPath.indexOf(MODEL_ID_ORG_SEPARATOR); - let modelStr: string; - - if (slashIdx !== MODEL_ID_NOT_FOUND) { - result.orgName = modelPath.slice(0, slashIdx); - modelStr = modelPath.slice(slashIdx + 1); - } else { - modelStr = modelPath; - } - - // 3. Handle dot-separated quantization (e.g. `model-name.Q4_K_M`) - const dotIdx = modelStr.lastIndexOf('.'); - - if (dotIdx !== MODEL_ID_NOT_FOUND && !result.quantization) { - const afterDot = modelStr.slice(dotIdx + 1); - - if (MODEL_QUANTIZATION_SEGMENT_RE.test(afterDot)) { - result.quantization = afterDot; - modelStr = modelStr.slice(0, dotIdx); - } - } - - const segments = modelStr.split(MODEL_ID_SEGMENT_SEPARATOR); - - // 4. Detect trailing quantization from dash-separated segments - // Handle UD-prefixed quantization (e.g. `UD-Q8_K_XL`) and - // standalone quantization (e.g. `Q4_K_M`, `BF16`, `F16`, `MXFP4`) - if (!result.quantization && segments.length > 1) { - const last = segments[segments.length - 1]; - const secondLast = segments.length > 2 ? segments[segments.length - 2] : null; - - if (MODEL_QUANTIZATION_SEGMENT_RE.test(last)) { - if (secondLast && MODEL_CUSTOM_QUANTIZATION_PREFIX_RE.test(secondLast)) { - result.quantization = `${secondLast}-${last}`; - segments.splice(segments.length - 2, 2); - } else { - result.quantization = last; - segments.pop(); - } - } - } - - // 5. Find params and activated params - let paramsIdx = MODEL_ID_NOT_FOUND; - let activatedParamsIdx = MODEL_ID_NOT_FOUND; - - for (let i = 0; i < segments.length; i++) { - const seg = segments[i]; - - if (paramsIdx === MODEL_ID_NOT_FOUND && MODEL_PARAMS_RE.test(seg)) { - paramsIdx = i; - result.params = seg.toUpperCase(); - } else if (paramsIdx !== MODEL_ID_NOT_FOUND && MODEL_ACTIVATED_PARAMS_RE.test(seg)) { - activatedParamsIdx = i; - result.activatedParams = seg.toUpperCase(); - } - } - - // 6. Model name = segments before params; tags = remaining segments after params - const pivotIdx = paramsIdx !== MODEL_ID_NOT_FOUND ? paramsIdx : segments.length; - - result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID_SEGMENT_SEPARATOR) || null; - - if (paramsIdx !== MODEL_ID_NOT_FOUND) { - result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => { - const absIdx = paramsIdx + 1 + relIdx; - if (absIdx === activatedParamsIdx) return false; - - return !MODEL_IGNORED_SEGMENTS.has(segments[absIdx].toUpperCase()); - }); - } - - return result; - } -} diff --git a/tools/server/webui/src/lib/services/parameter-sync.service.spec.ts b/tools/server/webui/src/lib/services/parameter-sync.service.spec.ts deleted file mode 100644 index cbb2605e1..000000000 --- a/tools/server/webui/src/lib/services/parameter-sync.service.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { ParameterSyncService } from './parameter-sync.service'; -import { ColorMode } from '$lib/enums'; - -describe('ParameterSyncService', () => { - describe('roundFloatingPoint', () => { - it('should fix JavaScript floating-point precision issues', () => { - // Test the specific values from the screenshot - const mockServerParams = { - top_p: 0.949999988079071, - min_p: 0.009999999776482582, - temperature: 0.800000011920929, - top_k: 40, - samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature'] - }; - - const result = ParameterSyncService.extractServerDefaults({ - ...mockServerParams, - // Add other required fields to match the API type - n_predict: 512, - seed: -1, - dynatemp_range: 0.0, - dynatemp_exponent: 1.0, - xtc_probability: 0.0, - xtc_threshold: 0.1, - typ_p: 1.0, - repeat_last_n: 64, - repeat_penalty: 1.0, - presence_penalty: 0.0, - frequency_penalty: 0.0, - dry_multiplier: 0.0, - dry_base: 1.75, - dry_allowed_length: 2, - dry_penalty_last_n: -1, - mirostat: 0, - mirostat_tau: 5.0, - mirostat_eta: 0.1, - stop: [], - max_tokens: -1, - n_keep: 0, - n_discard: 0, - ignore_eos: false, - stream: true, - logit_bias: [], - n_probs: 0, - min_keep: 0, - grammar: '', - grammar_lazy: false, - grammar_triggers: [], - preserved_tokens: [], - chat_format: '', - reasoning_format: '', - reasoning_in_content: false, - generation_prompt: '', - 'speculative.n_max': 0, - 'speculative.n_min': 0, - 'speculative.p_min': 0.0, - timings_per_token: false, - post_sampling_probs: false, - lora: [], - top_n_sigma: 0.0, - dry_sequence_breakers: [] - } as ApiLlamaCppServerProps['default_generation_settings']['params']); - - // Check that the problematic floating-point values are rounded correctly - expect(result.top_p).toBe(0.95); - expect(result.min_p).toBe(0.01); - expect(result.temperature).toBe(0.8); - expect(result.top_k).toBe(40); // Integer should remain unchanged - expect(result.samplers).toBe('top_k;typ_p;top_p;min_p;temperature'); - }); - - it('should preserve non-numeric values', () => { - const mockServerParams = { - samplers: ['top_k', 'temperature'], - max_tokens: -1, - temperature: 0.7 - }; - - const result = ParameterSyncService.extractServerDefaults({ - ...mockServerParams, - // Minimal required fields - n_predict: 512, - seed: -1, - dynatemp_range: 0.0, - dynatemp_exponent: 1.0, - top_k: 40, - top_p: 0.95, - min_p: 0.05, - xtc_probability: 0.0, - xtc_threshold: 0.1, - typ_p: 1.0, - repeat_last_n: 64, - repeat_penalty: 1.0, - presence_penalty: 0.0, - frequency_penalty: 0.0, - dry_multiplier: 0.0, - dry_base: 1.75, - dry_allowed_length: 2, - dry_penalty_last_n: -1, - mirostat: 0, - mirostat_tau: 5.0, - mirostat_eta: 0.1, - stop: [], - n_keep: 0, - n_discard: 0, - ignore_eos: false, - stream: true, - logit_bias: [], - n_probs: 0, - min_keep: 0, - grammar: '', - grammar_lazy: false, - grammar_triggers: [], - preserved_tokens: [], - chat_format: '', - reasoning_format: '', - reasoning_in_content: false, - generation_prompt: '', - 'speculative.n_max': 0, - 'speculative.n_min': 0, - 'speculative.p_min': 0.0, - timings_per_token: false, - post_sampling_probs: false, - lora: [], - top_n_sigma: 0.0, - dry_sequence_breakers: [] - } as ApiLlamaCppServerProps['default_generation_settings']['params']); - - expect(result.samplers).toBe('top_k;temperature'); - expect(result.max_tokens).toBe(-1); - expect(result.temperature).toBe(0.7); - }); - - it('should merge webui settings from props when provided', () => { - const result = ParameterSyncService.extractServerDefaults(null, { - pasteLongTextToFileLen: 0, - pdfAsImage: true, - renderUserContentAsMarkdown: false, - theme: ColorMode.DARK - }); - - expect(result.pasteLongTextToFileLen).toBe(0); - expect(result.pdfAsImage).toBe(true); - expect(result.renderUserContentAsMarkdown).toBe(false); - expect(result.theme).toBeUndefined(); - }); - }); -}); diff --git a/tools/server/webui/src/lib/services/parameter-sync.service.ts b/tools/server/webui/src/lib/services/parameter-sync.service.ts deleted file mode 100644 index 842aff7f9..000000000 --- a/tools/server/webui/src/lib/services/parameter-sync.service.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { normalizeFloatingPoint } from '$lib/utils'; -import { SETTINGS_KEYS, SYNCABLE_PARAMETERS } from '$lib/constants'; -import type { ParameterRecord, ParameterInfo, ParameterValue } from '$lib/types'; -import { SyncableParameterType, ParameterSource } from '$lib/enums'; - -export class ParameterSyncService { - /** - * - * - * Extraction - * - * - */ - - /** - * Round floating-point numbers to avoid JavaScript precision issues. - * E.g., 0.1 + 0.2 = 0.30000000000000004 → 0.3 - * - * @param value - Parameter value to normalize - * @returns Precision-normalized value - */ - private static roundFloatingPoint(value: ParameterValue): ParameterValue { - return normalizeFloatingPoint(value) as ParameterValue; - } - - /** - * Extract server default parameters that can be synced from `/props` response. - * Handles both generation settings parameters and webui-specific settings. - * Converts samplers array to semicolon-delimited string for UI display. - * - * @param serverParams - Raw generation settings from server `/props` endpoint - * @param webuiSettings - Optional webui-specific settings from server - * @returns Record of extracted parameter key-value pairs with normalized precision - */ - static extractServerDefaults( - serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null, - webuiSettings?: Record - ): ParameterRecord { - const extracted: ParameterRecord = {}; - - if (serverParams) { - for (const param of SYNCABLE_PARAMETERS) { - if (param.canSync && param.serverKey in serverParams) { - const value = (serverParams as unknown as Record)[ - param.serverKey - ]; - if (value !== undefined) { - // Apply precision rounding to avoid JavaScript floating-point issues - extracted[param.key] = this.roundFloatingPoint(value); - } - } - } - - // Handle samplers array conversion to string - if (serverParams.samplers && Array.isArray(serverParams.samplers)) { - extracted[SETTINGS_KEYS.SAMPLERS] = serverParams.samplers.join(';'); - } - } - - if (webuiSettings) { - for (const param of SYNCABLE_PARAMETERS) { - if (param.canSync && param.serverKey in webuiSettings) { - const value = webuiSettings[param.serverKey]; - if (value !== undefined) { - extracted[param.key] = this.roundFloatingPoint(value); - } - } - } - } - - return extracted; - } - - /** - * - * - * Merging - * - * - */ - - /** - * Merge server defaults with current user settings. - * User overrides always take priority — only parameters not in `userOverrides` - * set will be updated from server defaults. - * - * @param currentSettings - Current parameter values in the settings store - * @param serverDefaults - Default values extracted from server props - * @param userOverrides - Set of parameter keys explicitly overridden by the user - * @returns Merged parameter record with user overrides preserved - */ - static mergeWithServerDefaults( - currentSettings: ParameterRecord, - serverDefaults: ParameterRecord, - userOverrides: Set = new Set() - ): ParameterRecord { - const merged = { ...currentSettings }; - - for (const [key, serverValue] of Object.entries(serverDefaults)) { - // Only update if user hasn't explicitly overridden this parameter - if (!userOverrides.has(key)) { - merged[key] = this.roundFloatingPoint(serverValue); - } - } - - return merged; - } - - /** - * - * - * Info - * - * - */ - - /** - * Get parameter information including source and values. - * Used by SettingsChatParameterSourceIndicator to display the correct badge - * (Custom vs Default) for each parameter in the settings UI. - * - * @param key - The parameter key to get info for - * @param currentValue - The current value of the parameter - * @param propsDefaults - Server default values from `/props` - * @param userOverrides - Set of parameter keys explicitly overridden by the user - * @returns Parameter info with source, server default, and user override values - */ - static getParameterInfo( - key: string, - currentValue: ParameterValue, - propsDefaults: ParameterRecord, - userOverrides: Set - ): ParameterInfo { - const hasPropsDefault = propsDefaults[key] !== undefined; - const isUserOverride = userOverrides.has(key); - - // Simple logic: either using default (from props) or custom (user override) - const source = isUserOverride ? ParameterSource.CUSTOM : ParameterSource.DEFAULT; - - return { - value: currentValue, - source, - serverDefault: hasPropsDefault ? propsDefaults[key] : undefined, // Keep same field name for compatibility - userOverride: isUserOverride ? currentValue : undefined - }; - } - - /** - * Check if a parameter can be synced from server. - * - * @param key - The parameter key to check - * @returns True if the parameter is in the syncable parameters list - */ - static canSyncParameter(key: string): boolean { - return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync); - } - - /** - * Get all syncable parameter keys. - * - * @returns Array of parameter keys that can be synced from server - */ - static getSyncableParameterKeys(): string[] { - return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key); - } - - /** - * Validate a server parameter value against its expected type. - * - * @param key - The parameter key to validate - * @param value - The value to validate - * @returns True if value matches the expected type for this parameter - */ - static validateServerParameter(key: string, value: ParameterValue): boolean { - const param = SYNCABLE_PARAMETERS.find((p) => p.key === key); - if (!param) return false; - - switch (param.type) { - case SyncableParameterType.NUMBER: - return typeof value === 'number' && !isNaN(value); - case SyncableParameterType.STRING: - return typeof value === 'string'; - case SyncableParameterType.BOOLEAN: - return typeof value === 'boolean'; - default: - return false; - } - } - - /** - * - * - * Diff - * - * - */ - - /** - * Create a diff between current settings and server defaults. - * Shows which parameters differ from server values, useful for debugging - * and for the "Reset to defaults" functionality. - * - * @param currentSettings - Current parameter values in the settings store - * @param serverDefaults - Default values extracted from server props - * @returns Record of parameter diffs with current value, server value, and whether they differ - */ - static createParameterDiff( - currentSettings: ParameterRecord, - serverDefaults: ParameterRecord - ): Record { - const diff: Record< - string, - { current: ParameterValue; server: ParameterValue; differs: boolean } - > = {}; - - for (const key of this.getSyncableParameterKeys()) { - const currentValue = currentSettings[key]; - const serverValue = serverDefaults[key]; - - if (serverValue !== undefined) { - diff[key] = { - current: currentValue, - server: serverValue, - differs: currentValue !== serverValue - }; - } - } - - return diff; - } -} diff --git a/tools/server/webui/src/lib/services/props.service.ts b/tools/server/webui/src/lib/services/props.service.ts deleted file mode 100644 index 45c3e4577..000000000 --- a/tools/server/webui/src/lib/services/props.service.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { apiFetchWithParams } from '$lib/utils'; - -export class PropsService { - /** - * - * - * Fetching - * - * - */ - - /** - * Fetches global server properties from the `/props` endpoint. - * In MODEL mode, returns modalities for the single loaded model. - * In ROUTER mode, returns server-wide settings without model-specific modalities. - * - * @param autoload - If false, prevents automatic model loading (default: false) - * @returns Server properties including default generation settings and capabilities - * @throws {Error} If the request fails or returns invalid data - */ - static async fetch(autoload = false): Promise { - const params: Record = {}; - if (!autoload) { - params.autoload = 'false'; - } - - return apiFetchWithParams('./props', params, { authOnly: true }); - } - - /** - * Fetches server properties for a specific model (ROUTER mode only). - * Required in ROUTER mode because global `/props` does not include per-model modalities. - * - * @param modelId - The model ID to fetch properties for - * @param autoload - If false, prevents automatic model loading (default: false) - * @returns Server properties specific to the requested model - * @throws {Error} If the request fails, model not found, or model not loaded - */ - static async fetchForModel(modelId: string, autoload = false): Promise { - const params: Record = { model: modelId }; - if (!autoload) { - params.autoload = 'false'; - } - - return apiFetchWithParams('./props', params, { authOnly: true }); - } -} diff --git a/tools/server/webui/src/lib/services/router.service.ts b/tools/server/webui/src/lib/services/router.service.ts deleted file mode 100644 index 6fa172eec..000000000 --- a/tools/server/webui/src/lib/services/router.service.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ROUTES } from '$lib/constants/routes'; - -export class RouterService { - static chat(id: string): string { - return `${ROUTES.CHAT}/${id}`; - } - - static settings(section: string): string { - return `${ROUTES.SETTINGS}/${section}`; - } -} diff --git a/tools/server/webui/src/lib/services/tools.service.ts b/tools/server/webui/src/lib/services/tools.service.ts deleted file mode 100644 index 8f39f5209..000000000 --- a/tools/server/webui/src/lib/services/tools.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { apiFetch } from '$lib/utils'; -import { API_TOOLS } from '$lib/constants'; -import { ToolResponseField } from '$lib/enums'; -import type { ToolExecutionResult, ServerBuiltinToolInfo } from '$lib/types'; - -export class ToolsService { - /** - * Fetch the list of built-in tools from the server. - * - * @returns Array of tool definitions in OpenAI-compatible format - */ - static async list(): Promise { - return apiFetch(API_TOOLS.LIST); - } - - /** - * Execute a built-in tool on the server. - */ - static async executeTool( - toolName: string, - params: Record, - signal?: AbortSignal - ): Promise { - const result = await apiFetch>(API_TOOLS.EXECUTE, { - method: 'POST', - body: JSON.stringify({ tool: toolName, params }), - signal - }); - - if (ToolResponseField.ERROR in result) { - return { content: String(result[ToolResponseField.ERROR]), isError: true }; - } - - if (ToolResponseField.PLAIN_TEXT in result) { - return { content: String(result[ToolResponseField.PLAIN_TEXT]), isError: false }; - } - - return { content: JSON.stringify(result), isError: false }; - } -} diff --git a/tools/server/webui/src/lib/stores/agentic.svelte.ts b/tools/server/webui/src/lib/stores/agentic.svelte.ts deleted file mode 100644 index 1f1f05c45..000000000 --- a/tools/server/webui/src/lib/stores/agentic.svelte.ts +++ /dev/null @@ -1,1017 +0,0 @@ -/** - * agenticStore - Reactive State Store for Agentic Loop Orchestration - * - * Manages multi-turn agentic loop with MCP tools: - * - LLM streaming with tool call detection - * - Tool execution via mcpStore - * - Session state management - * - Turn limit enforcement - * - * Each agentic turn produces separate DB messages: - * - One assistant message per LLM turn (with tool_calls if any) - * - One tool result message per tool call execution - * - * **Architecture & Relationships:** - * - **ChatService**: Stateless API layer (sendMessage, streaming) - * - **mcpStore**: MCP connection management and tool execution - * - **agenticStore** (this): Reactive state + business logic - * - * @see ChatService in services/chat.service.ts for API operations - * @see mcpStore in stores/mcp.svelte.ts for MCP operations - */ - -import { ChatService } from '$lib/services'; -import { config } from '$lib/stores/settings.svelte'; -import { mcpStore } from '$lib/stores/mcp.svelte'; -import { modelsStore } from '$lib/stores/models.svelte'; -import { toolsStore } from '$lib/stores/tools.svelte'; -import { permissionsStore } from '$lib/stores/permissions.svelte'; -import { ToolSource, ToolPermissionDecision } from '$lib/enums'; -import { SvelteMap } from 'svelte/reactivity'; -import { ToolsService } from '$lib/services/tools.service'; -import { isAbortError } from '$lib/utils'; -import { DEFAULT_AGENTIC_CONFIG, NEWLINE_SEPARATOR } from '$lib/constants'; -import { - IMAGE_MIME_TO_EXTENSION, - DATA_URI_BASE64_REGEX, - MCP_ATTACHMENT_NAME_PREFIX, - DEFAULT_IMAGE_EXTENSION -} from '$lib/constants'; -import { - AttachmentType, - ContentPartType, - MessageRole, - MimeTypePrefix, - ToolCallType -} from '$lib/enums'; -import type { - AgenticFlowParams, - AgenticFlowResult, - AgenticSession, - AgenticConfig, - SettingsConfigType, - McpServerOverride, - MCPToolCall -} from '$lib/types'; -import type { - AgenticMessage, - AgenticToolCallList, - AgenticFlowCallbacks, - AgenticFlowOptions, - SteeringMessage -} from '$lib/types/agentic'; -import type { - ApiChatCompletionToolCall, - ApiChatMessageData, - ApiChatMessageContentPart -} from '$lib/types/api'; -import type { - ChatMessagePromptProgress, - ChatMessageTimings, - ChatMessageAgenticTimings, - ChatMessageToolCallTiming, - ChatMessageAgenticTurnStats -} from '$lib/types/chat'; -import type { - DatabaseMessage, - DatabaseMessageExtra, - DatabaseMessageExtraImageFile -} from '$lib/types/database'; - -function createDefaultSession(): AgenticSession { - return { - isRunning: false, - currentTurn: 0, - totalToolCalls: 0, - lastError: null, - streamingToolCall: null, - pendingPermissionRequest: null - }; -} - -function toAgenticMessages(messages: ApiChatMessageData[]): AgenticMessage[] { - return messages.map((message) => { - if ( - message.role === MessageRole.ASSISTANT && - message.tool_calls && - message.tool_calls.length > 0 - ) { - return { - role: MessageRole.ASSISTANT, - content: message.content, - reasoning_content: message.reasoning_content, - tool_calls: message.tool_calls.map((call, index) => ({ - id: call.id ?? `call_${index}`, - type: (call.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION, - function: { name: call.function?.name ?? '', arguments: call.function?.arguments ?? '' } - })) - } satisfies AgenticMessage; - } - if (message.role === MessageRole.ASSISTANT) { - return { - role: MessageRole.ASSISTANT, - content: message.content, - reasoning_content: message.reasoning_content - } satisfies AgenticMessage; - } - if (message.role === MessageRole.TOOL && message.tool_call_id) { - return { - role: MessageRole.TOOL, - tool_call_id: message.tool_call_id, - content: typeof message.content === 'string' ? message.content : '' - } satisfies AgenticMessage; - } - return { - role: message.role as MessageRole.SYSTEM | MessageRole.USER, - content: message.content - } satisfies AgenticMessage; - }); -} - -class AgenticStore { - private _sessions = new SvelteMap(); - /** Dedicated reactive state for pending permission requests (ensures immediate UI updates) */ - private _pendingPermissions = new SvelteMap< - string, - { toolName: string; serverLabel: string } | null - >(); - /** Non-reactive: stores resolve functions for pending permission Promises */ - private _permissionResolvers = new Map void>(); - - /** Dedicated reactive state for pending continue requests (turn limit reached) */ - private _pendingContinueRequests = new SvelteMap(); - /** Non-reactive: stores resolve functions for pending continue Promises */ - private _continueResolvers = new Map void>(); - - /** Reactive: queued steering messages to inject between turns */ - private _steeringMessages = new SvelteMap(); - - get isReady(): boolean { - return true; - } - get isAnyRunning(): boolean { - for (const session of this._sessions.values()) { - if (session.isRunning) return true; - } - return false; - } - - getSession(conversationId: string): AgenticSession { - let session = this._sessions.get(conversationId); - if (!session) { - session = createDefaultSession(); - this._sessions.set(conversationId, session); - } - return session; - } - - private updateSession(conversationId: string, update: Partial): void { - const session = this.getSession(conversationId); - this._sessions.set(conversationId, { ...session, ...update }); - } - - clearSession(conversationId: string): void { - this._sessions.delete(conversationId); - } - - getActiveSessions(): Array<{ conversationId: string; session: AgenticSession }> { - const active: Array<{ conversationId: string; session: AgenticSession }> = []; - for (const [conversationId, session] of this._sessions.entries()) { - if (session.isRunning) active.push({ conversationId, session }); - } - return active; - } - - isRunning(conversationId: string): boolean { - return this.getSession(conversationId).isRunning; - } - - currentTurn(conversationId: string): number { - return this.getSession(conversationId).currentTurn; - } - - totalToolCalls(conversationId: string): number { - return this.getSession(conversationId).totalToolCalls; - } - - lastError(conversationId: string): Error | null { - return this.getSession(conversationId).lastError; - } - - streamingToolCall(conversationId: string): { name: string; arguments: string } | null { - return this.getSession(conversationId).streamingToolCall; - } - - pendingPermissionRequest( - conversationId: string - ): { toolName: string; serverLabel: string } | null { - return this._pendingPermissions.get(conversationId) ?? null; - } - - pendingContinueRequest(conversationId: string): boolean { - return this._pendingContinueRequests.get(conversationId) ?? false; - } - - resolveContinue(conversationId: string, shouldContinue: boolean): void { - const resolver = this._continueResolvers.get(conversationId); - if (resolver) { - this._continueResolvers.delete(conversationId); - resolver(shouldContinue); - } - } - - resolvePermission(conversationId: string, decision: ToolPermissionDecision): void { - const resolver = this._permissionResolvers.get(conversationId); - if (resolver) { - this._permissionResolvers.delete(conversationId); - resolver(decision); - } - } - - clearError(conversationId: string): void { - this.updateSession(conversationId, { lastError: null }); - } - - hasPendingSteeringMessage(conversationId: string): boolean { - return this._steeringMessages.has(conversationId); - } - - pendingSteeringMessageContent(conversationId: string): string | null { - return this._steeringMessages.get(conversationId)?.content ?? null; - } - - pendingSteeringMessageExtras(conversationId: string): DatabaseMessageExtra[] | undefined { - return this._steeringMessages.get(conversationId)?.extras; - } - - /** - * Queue a steering message. When the current agentic turn completes, - * the flow exits and the caller re-sends the message as a normal chat message. - */ - injectSteeringMessage( - conversationId: string, - content: string, - extras?: DatabaseMessageExtra[] - ): void { - this._steeringMessages.set(conversationId, { content, extras }); - } - - /** - * Clear the pending steering message without consuming it. - */ - clearSteeringMessage(conversationId: string): void { - this._steeringMessages.delete(conversationId); - } - - /** - * Consume and return the pending steering message for re-sending. - * Called by chatStore after the agentic flow exits. - */ - consumePendingSteeringMessage(conversationId: string): SteeringMessage | null { - const msg = this._steeringMessages.get(conversationId); - if (!msg) return null; - this._steeringMessages.delete(conversationId); - return msg; - } - - getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig { - const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns; - const maxToolPreviewLines = - Number(settings.agenticMaxToolPreviewLines) || DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines; - const hasTools = - mcpStore.hasEnabledServers(perChatOverrides) || - toolsStore.builtinTools.length > 0 || - toolsStore.customTools.length > 0; - return { - enabled: hasTools && DEFAULT_AGENTIC_CONFIG.enabled, - maxTurns, - maxToolPreviewLines - }; - } - - private parseToolArguments(args: string | Record): Record { - if (typeof args === 'object') return args; - const trimmed = args.trim(); - if (trimmed === '') return {}; - return JSON.parse(trimmed) as Record; - } - - private async requestPermission( - conversationId: string, - toolName: string, - serverLabel: string, - signal?: AbortSignal - ): Promise { - const permissionKey = toolsStore.getPermissionKey(toolName); - if (permissionKey && permissionsStore.hasTool(permissionKey)) { - return ToolPermissionDecision.ONCE; - } - - this._pendingPermissions.set(conversationId, { toolName, serverLabel }); - - return new Promise((resolve) => { - if (signal?.aborted) { - this._pendingPermissions.set(conversationId, null); - resolve(ToolPermissionDecision.DENY); - return; - } - - this._permissionResolvers.set(conversationId, (decision) => { - this._pendingPermissions.set(conversationId, null); - if (decision === ToolPermissionDecision.ALWAYS && permissionKey) { - permissionsStore.allowTool(permissionKey); - } else if (decision === ToolPermissionDecision.ALWAYS_SERVER) { - const serverToolKeys = toolsStore.allTools - .filter((t) => - t.serverName - ? t.serverName === serverLabel - : toolsStore.getToolServerLabel(t.definition.function.name) === serverLabel - ) - .map((t) => toolsStore.getPermissionKey(t.definition.function.name)!) - .filter((k): k is string => k !== null); - permissionsStore.allowTools(serverToolKeys); - } - resolve(decision); - }); - - signal?.addEventListener( - 'abort', - () => { - const resolver = this._permissionResolvers.get(conversationId); - if (resolver) { - this._permissionResolvers.delete(conversationId); - this._pendingPermissions.set(conversationId, null); - resolve(ToolPermissionDecision.DENY); - } - }, - { once: true } - ); - }); - } - - private async requestContinue(conversationId: string, signal?: AbortSignal): Promise { - this._pendingContinueRequests.set(conversationId, true); - - return new Promise((resolve) => { - if (signal?.aborted) { - this._pendingContinueRequests.set(conversationId, false); - resolve(false); - return; - } - - this._continueResolvers.set(conversationId, (shouldContinue) => { - this._pendingContinueRequests.set(conversationId, false); - resolve(shouldContinue); - }); - - signal?.addEventListener( - 'abort', - () => { - const resolver = this._continueResolvers.get(conversationId); - if (resolver) { - this._continueResolvers.delete(conversationId); - this._pendingContinueRequests.set(conversationId, false); - resolve(false); - } - }, - { once: true } - ); - }); - } - - async runAgenticFlow(params: AgenticFlowParams): Promise { - const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params; - - // Clear any pending permissions/continue requests for this conversation when starting a new flow - this._pendingPermissions.set(conversationId, null); - this._permissionResolvers.delete(conversationId); - this._pendingContinueRequests.set(conversationId, false); - this._continueResolvers.delete(conversationId); - this._steeringMessages.delete(conversationId); - - // Ensure built-in tools are fetched before checking if agentic is enabled - if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) { - await toolsStore.fetchBuiltinTools(); - } - - const agenticConfig = this.getConfig(config(), perChatOverrides); - if (!agenticConfig.enabled) return { handled: false }; - - const hasMcpServers = mcpStore.hasEnabledServers(perChatOverrides); - if (hasMcpServers) { - const initialized = await mcpStore.ensureInitialized(perChatOverrides); - - if (!initialized) { - console.log('[AgenticStore] MCP not initialized'); - } - } - - const tools = toolsStore.getEnabledToolsForLLM(); - if (tools.length === 0) { - return { handled: false }; - } - - console.log(`[AgenticStore] Starting agentic flow with ${tools.length} tools`); - - const normalizedMessages: ApiChatMessageData[] = messages - .map((msg) => { - if ('id' in msg && 'convId' in msg && 'timestamp' in msg) - return ChatService.convertDbMessageToApiChatMessageData( - msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] } - ); - return msg as ApiChatMessageData; - }) - .filter((msg) => { - if (msg.role === MessageRole.SYSTEM) { - const content = typeof msg.content === 'string' ? msg.content : ''; - return content.trim().length > 0; - } - return true; - }); - - this.updateSession(conversationId, { - isRunning: true, - currentTurn: 0, - totalToolCalls: 0, - lastError: null - }); - - if (hasMcpServers) mcpStore.acquireConnection(); - - try { - await this.executeAgenticLoop({ - conversationId, - messages: normalizedMessages, - options, - tools, - agenticConfig, - callbacks, - signal - }); - return { handled: true }; - } catch (error) { - const normalizedError = error instanceof Error ? error : new Error(String(error)); - this.updateSession(conversationId, { lastError: normalizedError }); - callbacks.onError?.(normalizedError); - return { handled: true, error: normalizedError }; - } finally { - this.updateSession(conversationId, { isRunning: false }); - - if (hasMcpServers) { - await mcpStore - .releaseConnection() - .catch((err: unknown) => - console.warn('[AgenticStore] Failed to release MCP connection:', err) - ); - } - } - } - - private async executeAgenticLoop(params: { - conversationId: string; - messages: ApiChatMessageData[]; - options: AgenticFlowOptions; - tools: ReturnType; - agenticConfig: AgenticConfig; - callbacks: AgenticFlowCallbacks; - signal?: AbortSignal; - }): Promise { - const { conversationId, messages, options, tools, agenticConfig, callbacks, signal } = params; - const { - onChunk, - onReasoningChunk, - onToolCallsStreaming, - onAttachments, - onModel, - onAssistantTurnComplete, - createToolResultMessage, - createAssistantMessage, - onFlowComplete, - onTimings, - onTurnComplete - } = callbacks; - - const sessionMessages: AgenticMessage[] = toAgenticMessages(messages); - let capturedTimings: ChatMessageTimings | undefined; - let totalToolCallCount = 0; - - const agenticTimings: ChatMessageAgenticTimings = { - turns: 0, - toolCallsCount: 0, - toolsMs: 0, - toolCalls: [], - perTurn: [], - llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 } - }; - const maxTurns = agenticConfig.maxTurns; - - const effectiveModel = options.model || modelsStore.models[0]?.model || ''; - - let turn = 0; - while (true) { - if (turn >= maxTurns) { - // Turn limit reached - ask user whether to continue - const shouldContinue = await this.requestContinue(conversationId, signal); - - // Yield to allow Svelte to flush the UI update - await new Promise((r) => setTimeout(r, 0)); - - if (!shouldContinue || signal?.aborted) { - onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); - return; - } - - // User chose to continue - extend the limit - turn = 0; - } - - this.updateSession(conversationId, { currentTurn: turn + 1 }); - agenticTimings.turns = turn + 1; - - if (signal?.aborted) { - onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); - return; - } - - // For turns > 0, create a new assistant message via callback - if (turn > 0 && createAssistantMessage) { - await createAssistantMessage(); - } - - let turnContent = ''; - let turnReasoningContent = ''; - let turnToolCalls: ApiChatCompletionToolCall[] = []; - let lastStreamingToolCallName = ''; - let lastStreamingToolCallArgsLength = 0; - let turnTimings: ChatMessageTimings | undefined; - - const turnStats: ChatMessageAgenticTurnStats = { - turn: turn + 1, - llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 }, - toolCalls: [], - toolsMs: 0 - }; - - try { - await ChatService.sendMessage( - sessionMessages as ApiChatMessageData[], - { - ...options, - stream: true, - tools: tools.length > 0 ? tools : undefined, - onChunk: (chunk: string) => { - turnContent += chunk; - onChunk?.(chunk); - }, - onReasoningChunk: (chunk: string) => { - turnReasoningContent += chunk; - onReasoningChunk?.(chunk); - }, - onToolCallChunk: (serialized: string) => { - try { - turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[]; - onToolCallsStreaming?.(turnToolCalls); - - if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) { - const name = turnToolCalls[0].function.name || ''; - const args = turnToolCalls[0].function.arguments || ''; - const argsLengthBucket = Math.floor(args.length / 100); - if ( - name !== lastStreamingToolCallName || - argsLengthBucket !== lastStreamingToolCallArgsLength - ) { - lastStreamingToolCallName = name; - lastStreamingToolCallArgsLength = argsLengthBucket; - this.updateSession(conversationId, { - streamingToolCall: { name, arguments: args } - }); - } - } - } catch { - /* Ignore parse errors during streaming */ - } - }, - onModel, - onTimings: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => { - onTimings?.(timings, progress); - if (timings) { - capturedTimings = timings; - turnTimings = timings; - } - }, - onComplete: () => { - /* Completion handled after sendMessage resolves */ - }, - onError: (error: Error) => { - throw error; - } - }, - undefined, - signal - ); - - this.updateSession(conversationId, { streamingToolCall: null }); - - if (turnTimings) { - agenticTimings.llm.predicted_n += turnTimings.predicted_n || 0; - agenticTimings.llm.predicted_ms += turnTimings.predicted_ms || 0; - agenticTimings.llm.prompt_n += turnTimings.prompt_n || 0; - agenticTimings.llm.prompt_ms += turnTimings.prompt_ms || 0; - turnStats.llm.predicted_n = turnTimings.predicted_n || 0; - turnStats.llm.predicted_ms = turnTimings.predicted_ms || 0; - turnStats.llm.prompt_n = turnTimings.prompt_n || 0; - turnStats.llm.prompt_ms = turnTimings.prompt_ms || 0; - } - } catch (error) { - if (signal?.aborted) { - // Save whatever we have for this turn before exiting - await onAssistantTurnComplete?.( - turnContent, - turnReasoningContent || undefined, - this.buildFinalTimings(capturedTimings, agenticTimings), - undefined - ); - onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); - return; - } - const normalizedError = error instanceof Error ? error : new Error('LLM stream error'); - // preserve partial output as is, the outer error dialog informs the user separately - await onAssistantTurnComplete?.( - turnContent, - turnReasoningContent || undefined, - this.buildFinalTimings(capturedTimings, agenticTimings), - undefined - ); - onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); - throw normalizedError; - } - - // === Steering check: if a user message was queued during this turn, exit the flow. - // The caller (chatStore) will consume the pending message and re-send it normally. - if (this._steeringMessages.has(conversationId)) { - console.log('[AgenticStore] Steering message detected after turn, exiting agentic flow'); - await onAssistantTurnComplete?.( - turnContent, - turnReasoningContent || undefined, - this.buildFinalTimings(capturedTimings, agenticTimings), - turnToolCalls.length > 0 ? this.normalizeToolCalls(turnToolCalls) : undefined - ); - onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); - return; - } - - // No tool calls = final turn, save and complete - if (turnToolCalls.length === 0) { - agenticTimings.perTurn!.push(turnStats); - - const finalTimings = this.buildFinalTimings(capturedTimings, agenticTimings); - - await onAssistantTurnComplete?.( - turnContent, - turnReasoningContent || undefined, - finalTimings, - undefined - ); - - if (finalTimings) onTurnComplete?.(finalTimings); - - onFlowComplete?.(finalTimings); - - return; - } - - // Normalize and save assistant turn with tool calls - const normalizedCalls = this.normalizeToolCalls(turnToolCalls); - if (normalizedCalls.length === 0) { - await onAssistantTurnComplete?.( - turnContent, - turnReasoningContent || undefined, - this.buildFinalTimings(capturedTimings, agenticTimings), - undefined - ); - onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); - return; - } - - totalToolCallCount += normalizedCalls.length; - this.updateSession(conversationId, { totalToolCalls: totalToolCallCount }); - - // Save the assistant message with its tool calls - await onAssistantTurnComplete?.( - turnContent, - turnReasoningContent || undefined, - turnTimings, - normalizedCalls - ); - - // Add assistant message to session history - sessionMessages.push({ - role: MessageRole.ASSISTANT, - content: turnContent || undefined, - reasoning_content: turnReasoningContent || undefined, - tool_calls: normalizedCalls - }); - - // Execute each tool call and create result messages - for (let i = 0; i < normalizedCalls.length; i++) { - const toolCall = normalizedCalls[i]; - - if (signal?.aborted) { - onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); - return; - } - - // Check for pending steering message - skip remaining tool calls - if (this._steeringMessages.has(conversationId)) { - console.log( - `[AgenticStore] Steering message detected, skipping ${normalizedCalls.length - i} remaining tool call(s)` - ); - for (let j = i; j < normalizedCalls.length; j++) { - const remainingCall = normalizedCalls[j]; - const interruptedContent = 'Tool execution was interrupted by a new user message.'; - if (createToolResultMessage) { - await createToolResultMessage(remainingCall.id, interruptedContent); - } - sessionMessages.push({ - role: MessageRole.TOOL, - tool_call_id: remainingCall.id, - content: interruptedContent - }); - } - break; - } - - const toolName = toolCall.function.name; - const serverLabel = toolsStore.getToolServerLabel(toolName); - - // Ask for permission before executing the tool - const permission = await this.requestPermission( - conversationId, - toolName, - serverLabel, - signal - ); - - // Yield to allow Svelte to flush the UI update (hide permission dialog) - await new Promise((r) => setTimeout(r, 0)); - - if (signal?.aborted) { - onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); - return; - } - - const toolStartTime = performance.now(); - const toolSource = toolsStore.getToolSource(toolName); - - let result: string; - let toolSuccess = true; - - if (permission === ToolPermissionDecision.DENY) { - result = 'Tool execution was denied by the user.'; - toolSuccess = false; - } else { - try { - if (toolSource === ToolSource.BUILTIN) { - const args = this.parseToolArguments(toolCall.function.arguments); - const executionResult = await ToolsService.executeTool(toolName, args, signal); - - result = executionResult.content; - - if (executionResult.isError) toolSuccess = false; - } else { - const mcpCall: MCPToolCall = { - id: toolCall.id, - function: { name: toolName, arguments: toolCall.function.arguments } - }; - const executionResult = await mcpStore.executeTool(mcpCall, signal); - - result = executionResult.content; - } - } catch (error) { - if (isAbortError(error)) { - onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); - return; - } - result = `Error: ${error instanceof Error ? error.message : String(error)}`; - toolSuccess = false; - } - } - - const toolDurationMs = performance.now() - toolStartTime; - const toolTiming: ChatMessageToolCallTiming = { - name: toolCall.function.name, - duration_ms: Math.round(toolDurationMs), - success: toolSuccess - }; - - agenticTimings.toolCalls!.push(toolTiming); - agenticTimings.toolCallsCount++; - agenticTimings.toolsMs += Math.round(toolDurationMs); - turnStats.toolCalls.push(toolTiming); - turnStats.toolsMs += Math.round(toolDurationMs); - - if (signal?.aborted) { - onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); - return; - } - - const { cleanedResult, attachments } = this.extractBase64Attachments(result); - - // Create the tool result message in the DB - let toolResultMessage: DatabaseMessage | undefined; - if (createToolResultMessage) { - toolResultMessage = await createToolResultMessage( - toolCall.id, - cleanedResult, - attachments.length > 0 ? attachments : undefined - ); - } - - if (attachments.length > 0 && toolResultMessage) { - onAttachments?.(toolResultMessage.id, attachments); - } - - // Build content parts for session history (including images for vision models) - const contentParts: ApiChatMessageContentPart[] = [ - { type: ContentPartType.TEXT, text: cleanedResult } - ]; - for (const attachment of attachments) { - if (attachment.type === AttachmentType.IMAGE) { - if (modelsStore.modelSupportsVision(effectiveModel)) { - contentParts.push({ - type: ContentPartType.IMAGE_URL, - image_url: { url: (attachment as DatabaseMessageExtraImageFile).base64Url } - }); - } else { - console.info( - `[AgenticStore] Skipping image attachment (model "${effectiveModel}" does not support vision)` - ); - } - } - } - - sessionMessages.push({ - role: MessageRole.TOOL, - tool_call_id: toolCall.id, - content: contentParts.length === 1 ? cleanedResult : contentParts - }); - } - - if (turnStats.toolCalls.length > 0) { - agenticTimings.perTurn!.push(turnStats); - - const intermediateTimings = this.buildFinalTimings(capturedTimings, agenticTimings); - if (intermediateTimings) onTurnComplete?.(intermediateTimings); - } - - // If tools were interrupted by a steering message, exit now instead of starting another LLM turn - if (this._steeringMessages.has(conversationId)) { - console.log( - '[AgenticStore] Steering message detected after tool execution, exiting agentic flow' - ); - onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); - return; - } - - turn++; - } - } - - private buildFinalTimings( - capturedTimings: ChatMessageTimings | undefined, - agenticTimings: ChatMessageAgenticTimings - ): ChatMessageTimings | undefined { - if (agenticTimings.toolCallsCount === 0) return capturedTimings; - return { - predicted_n: capturedTimings?.predicted_n, - predicted_ms: capturedTimings?.predicted_ms, - prompt_n: capturedTimings?.prompt_n, - prompt_ms: capturedTimings?.prompt_ms, - cache_n: capturedTimings?.cache_n, - agentic: agenticTimings - }; - } - - private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList { - if (!toolCalls) return []; - return toolCalls.map((call, index) => ({ - id: call?.id ?? `tool_${index}`, - type: (call?.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION, - function: { name: call?.function?.name ?? '', arguments: call?.function?.arguments ?? '' } - })); - } - - private extractBase64Attachments(result: string): { - cleanedResult: string; - attachments: DatabaseMessageExtra[]; - } { - if (!result.trim()) { - return { cleanedResult: result, attachments: [] }; - } - - const lines = result.split(NEWLINE_SEPARATOR); - const attachments: DatabaseMessageExtra[] = []; - let attachmentIndex = 0; - - const cleanedLines = lines.map((line) => { - const trimmedLine = line.trim(); - - const match = trimmedLine.match(DATA_URI_BASE64_REGEX); - if (!match) { - return line; - } - - const mimeType = match[1].toLowerCase(); - const base64Data = match[2]; - - if (!base64Data) { - return line; - } - - attachmentIndex += 1; - const name = this.buildAttachmentName(mimeType, attachmentIndex); - - if (mimeType.startsWith(MimeTypePrefix.IMAGE)) { - attachments.push({ type: AttachmentType.IMAGE, name, base64Url: trimmedLine }); - - return `[Attachment saved: ${name}]`; - } - - return line; - }); - - return { cleanedResult: cleanedLines.join(NEWLINE_SEPARATOR), attachments }; - } - - private buildAttachmentName(mimeType: string, index: number): string { - const extension = IMAGE_MIME_TO_EXTENSION[mimeType] ?? DEFAULT_IMAGE_EXTENSION; - - return `${MCP_ATTACHMENT_NAME_PREFIX}-${Date.now()}-${index}.${extension}`; - } -} - -export const agenticStore = new AgenticStore(); - -export function agenticIsRunning(conversationId: string) { - return agenticStore.isRunning(conversationId); -} - -export function agenticCurrentTurn(conversationId: string) { - return agenticStore.currentTurn(conversationId); -} - -export function agenticTotalToolCalls(conversationId: string) { - return agenticStore.totalToolCalls(conversationId); -} - -export function agenticLastError(conversationId: string) { - return agenticStore.lastError(conversationId); -} - -export function agenticStreamingToolCall(conversationId: string) { - return agenticStore.streamingToolCall(conversationId); -} - -export function agenticPendingPermissionRequest(conversationId: string) { - return agenticStore.pendingPermissionRequest(conversationId); -} - -export function agenticResolvePermission(conversationId: string, decision: ToolPermissionDecision) { - agenticStore.resolvePermission(conversationId, decision); -} - -export function agenticPendingContinueRequest(conversationId: string) { - return agenticStore.pendingContinueRequest(conversationId); -} - -export function agenticResolveContinue(conversationId: string, shouldContinue: boolean) { - agenticStore.resolveContinue(conversationId, shouldContinue); -} - -export function agenticHasPendingSteeringMessage(conversationId: string) { - return agenticStore.hasPendingSteeringMessage(conversationId); -} - -export function agenticInjectSteeringMessage( - conversationId: string, - content: string, - extras?: DatabaseMessageExtra[] -) { - agenticStore.injectSteeringMessage(conversationId, content, extras); -} - -export function agenticPendingSteeringMessageContent(conversationId: string) { - return agenticStore.pendingSteeringMessageContent(conversationId); -} - -export function agenticPendingSteeringMessageExtras(conversationId: string) { - return agenticStore.pendingSteeringMessageExtras(conversationId); -} - -export function agenticClearSteeringMessage(conversationId: string) { - agenticStore.clearSteeringMessage(conversationId); -} - -export function agenticIsAnyRunning() { - return agenticStore.isAnyRunning; -} diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts deleted file mode 100644 index 04a735eec..000000000 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ /dev/null @@ -1,1867 +0,0 @@ -/** - * chatStore - Reactive State Store for Chat Operations - * - * Manages chat lifecycle, streaming, message operations, and processing state. - * - * **Architecture & Relationships:** - * - **ChatService**: Stateless API layer (sendMessage, streaming) - * - **chatStore** (this): Reactive state + business logic - * - **conversationsStore**: Conversation persistence and navigation - * - * @see ChatService in services/chat.service.ts for API operations - */ - -import { SvelteMap } from 'svelte/reactivity'; -import { DatabaseService } from '$lib/services/database.service'; -import { ChatService } from '$lib/services/chat.service'; -import { conversationsStore } from '$lib/stores/conversations.svelte'; -import { config } from '$lib/stores/settings.svelte'; -import { agenticStore } from '$lib/stores/agentic.svelte'; -import { mcpStore } from '$lib/stores/mcp.svelte'; -import { contextSize, isRouterMode } from '$lib/stores/server.svelte'; -import { - selectedModelName, - modelsStore, - selectedModelContextSize -} from '$lib/stores/models.svelte'; -import { - normalizeModelName, - filterByLeafNodeId, - findDescendantMessages, - findLeafNode, - findMessageById, - isAbortError, - generateConversationTitle -} from '$lib/utils'; -import { - MAX_INACTIVE_CONVERSATION_STATES, - INACTIVE_CONVERSATION_STATE_MAX_AGE_MS, - SYSTEM_MESSAGE_PLACEHOLDER, - TITLE_GENERATION -} from '$lib/constants'; -import type { - ChatMessageTimings, - ChatMessagePromptProgress, - ChatStreamCallbacks, - ErrorDialogState -} from '$lib/types/chat'; -import type { - ApiChatMessageData, - ApiProcessingState, - DatabaseMessage, - DatabaseMessageExtra -} from '$lib/types'; -import { ErrorDialogType, MessageRole, MessageType } from '$lib/enums'; - -interface ConversationStateEntry { - lastAccessed: number; -} - -class ChatStore { - activeProcessingState = $state(null); - currentResponse = $state(''); - errorDialogState = $state(null); - isLoading = $state(false); - chatLoadingStates = new SvelteMap(); - chatStreamingStates = new SvelteMap(); - private abortControllers = new SvelteMap(); - private preEncodeAbortController: AbortController | null = null; - private processingStates = new SvelteMap(); - private conversationStateTimestamps = new SvelteMap(); - private activeConversationId = $state(null); - private isStreamingActive = $state(false); - private isEditModeActive = $state(false); - private addFilesHandler: ((files: File[]) => void) | null = $state(null); - pendingEditMessageId = $state(null); - private messageUpdateCallback: - | ((messageId: string, updates: Partial) => void) - | null = null; - private _pendingDraftMessage = $state(''); - private _pendingDraftFiles = $state([]); - - /** Reactive: queued pending messages for non-agentic streaming */ - private _pendingMessages = new SvelteMap< - string, - { content: string; extras?: DatabaseMessageExtra[] } - >(); - - private setChatLoading(convId: string, loading: boolean): void { - this.touchConversationState(convId); - if (loading) { - this.chatLoadingStates.set(convId, true); - if (convId === conversationsStore.activeConversation?.id) this.isLoading = true; - } else { - this.chatLoadingStates.delete(convId); - if (convId === conversationsStore.activeConversation?.id) this.isLoading = false; - } - } - private setChatStreaming(convId: string, response: string, messageId: string): void { - this.touchConversationState(convId); - this.chatStreamingStates.set(convId, { response, messageId }); - if (convId === conversationsStore.activeConversation?.id) this.currentResponse = response; - } - private clearChatStreaming(convId: string): void { - this.chatStreamingStates.delete(convId); - if (convId === conversationsStore.activeConversation?.id) this.currentResponse = ''; - } - private getChatStreaming(convId: string): { response: string; messageId: string } | undefined { - return this.chatStreamingStates.get(convId); - } - syncLoadingStateForChat(convId: string): void { - this.isLoading = this.chatLoadingStates.get(convId) || false; - const s = this.chatStreamingStates.get(convId); - this.currentResponse = s?.response || ''; - this.isStreamingActive = s !== undefined; - this.setActiveProcessingConversation(convId); - // Sync streaming content to activeMessages so UI displays current content - if (s?.response && s?.messageId) { - const idx = conversationsStore.findMessageIndex(s.messageId); - if (idx !== -1) { - conversationsStore.updateMessageAtIndex(idx, { content: s.response }); - } - } - } - - clearUIState(): void { - this.isLoading = false; - this.currentResponse = ''; - this.isStreamingActive = false; - } - - setActiveProcessingConversation(conversationId: string | null): void { - this.activeConversationId = conversationId; - this.activeProcessingState = conversationId - ? this.processingStates.get(conversationId) || null - : null; - } - - getProcessingState(conversationId: string): ApiProcessingState | null { - return this.processingStates.get(conversationId) || null; - } - - private setProcessingState(conversationId: string, state: ApiProcessingState | null): void { - if (state === null) this.processingStates.delete(conversationId); - else this.processingStates.set(conversationId, state); - if (conversationId === this.activeConversationId) this.activeProcessingState = state; - } - - clearProcessingState(conversationId: string): void { - this.processingStates.delete(conversationId); - if (conversationId === this.activeConversationId) this.activeProcessingState = null; - } - - getActiveProcessingState(): ApiProcessingState | null { - return this.activeProcessingState; - } - - getCurrentProcessingStateSync(): ApiProcessingState | null { - return this.activeProcessingState; - } - - private setStreamingActive(active: boolean): void { - this.isStreamingActive = active; - } - - isStreaming(): boolean { - return this.isStreamingActive; - } - - private getOrCreateAbortController(convId: string): AbortController { - let c = this.abortControllers.get(convId); - if (!c || c.signal.aborted) { - c = new AbortController(); - this.abortControllers.set(convId, c); - } - return c; - } - - private abortRequest(convId?: string): void { - if (convId) { - const c = this.abortControllers.get(convId); - if (c) { - c.abort(); - this.abortControllers.delete(convId); - } - } else { - for (const c of this.abortControllers.values()) c.abort(); - this.abortControllers.clear(); - } - } - - /** - * Abort the current agentic flow signal without clearing loading state. - * Used by "Send immediately" to force the agentic loop to exit so that - * the pending steering message can be re-sent. - */ - abortCurrentFlow(convId: string): void { - const c = this.abortControllers.get(convId); - if (c) { - c.abort(); - this.abortControllers.delete(convId); - } - } - - private showErrorDialog(state: ErrorDialogState | null): void { - this.errorDialogState = state; - } - - dismissErrorDialog(): void { - this.errorDialogState = null; - } - - clearEditMode(): void { - this.isEditModeActive = false; - this.addFilesHandler = null; - } - - isEditing(): boolean { - return this.isEditModeActive; - } - - setEditModeActive(handler: (files: File[]) => void): void { - this.isEditModeActive = true; - this.addFilesHandler = handler; - } - - getAddFilesHandler(): ((files: File[]) => void) | null { - return this.addFilesHandler; - } - - clearPendingEditMessageId(): void { - this.pendingEditMessageId = null; - } - - savePendingDraft(message: string, files: ChatUploadedFile[]): void { - this._pendingDraftMessage = message; - this._pendingDraftFiles = [...files]; - } - - consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null { - if (!this._pendingDraftMessage && this._pendingDraftFiles.length === 0) return null; - const d = { message: this._pendingDraftMessage, files: [...this._pendingDraftFiles] }; - this._pendingDraftMessage = ''; - this._pendingDraftFiles = []; - return d; - } - - hasPendingDraft(): boolean { - return Boolean(this._pendingDraftMessage) || this._pendingDraftFiles.length > 0; - } - - getAllLoadingChats(): string[] { - return Array.from(this.chatLoadingStates.keys()); - } - - getAllStreamingChats(): string[] { - return Array.from(this.chatStreamingStates.keys()); - } - - getChatStreamingPublic(convId: string): { response: string; messageId: string } | undefined { - return this.getChatStreaming(convId); - } - - isChatLoadingPublic(convId: string): boolean { - return this.chatLoadingStates.get(convId) || false; - } - - private isChatLoadingInternal(convId: string): boolean { - return this.chatLoadingStates.has(convId) || this.chatStreamingStates.has(convId); - } - - hasPendingMessage(convId: string): boolean { - return this._pendingMessages.has(convId); - } - - pendingMessageContent(convId: string): string | null { - return this._pendingMessages.get(convId)?.content ?? null; - } - - pendingMessageExtras(convId: string): DatabaseMessageExtra[] | undefined { - return this._pendingMessages.get(convId)?.extras; - } - - injectPendingMessage(convId: string, content: string, extras?: DatabaseMessageExtra[]): void { - this._pendingMessages.set(convId, { content, extras }); - } - - clearPendingMessage(convId: string): void { - this._pendingMessages.delete(convId); - } - - consumePendingMessage( - convId: string - ): { content: string; extras?: DatabaseMessageExtra[] } | null { - const msg = this._pendingMessages.get(convId); - if (!msg) return null; - this._pendingMessages.delete(convId); - return msg; - } - - private touchConversationState(convId: string): void { - this.conversationStateTimestamps.set(convId, { lastAccessed: Date.now() }); - } - - cleanupOldConversationStates(activeConversationIds?: string[]): number { - const now = Date.now(); - const activeIdsList = activeConversationIds ?? []; - const preserveIds = this.activeConversationId - ? [...activeIdsList, this.activeConversationId] - : activeIdsList; - const allConvIds = [ - ...new Set([ - ...this.chatLoadingStates.keys(), - ...this.chatStreamingStates.keys(), - ...this.abortControllers.keys(), - ...this.processingStates.keys(), - ...this.conversationStateTimestamps.keys() - ]) - ]; - const cleanupCandidates: Array<{ convId: string; lastAccessed: number }> = []; - for (const convId of allConvIds) { - if (preserveIds.includes(convId)) continue; - if (this.chatLoadingStates.get(convId)) continue; - if (this.chatStreamingStates.has(convId)) continue; - const ts = this.conversationStateTimestamps.get(convId); - cleanupCandidates.push({ convId, lastAccessed: ts?.lastAccessed ?? 0 }); - } - cleanupCandidates.sort((a, b) => a.lastAccessed - b.lastAccessed); - let cleanedUp = 0; - for (const { convId, lastAccessed } of cleanupCandidates) { - if ( - cleanupCandidates.length - cleanedUp > MAX_INACTIVE_CONVERSATION_STATES || - now - lastAccessed > INACTIVE_CONVERSATION_STATE_MAX_AGE_MS - ) { - this.cleanupConversationState(convId); - cleanedUp++; - } - } - return cleanedUp; - } - private cleanupConversationState(convId: string): void { - const c = this.abortControllers.get(convId); - if (c && !c.signal.aborted) c.abort(); - this.chatLoadingStates.delete(convId); - this.chatStreamingStates.delete(convId); - this.abortControllers.delete(convId); - this.processingStates.delete(convId); - this.conversationStateTimestamps.delete(convId); - } - getTrackedConversationCount(): number { - return new Set([ - ...this.chatLoadingStates.keys(), - ...this.chatStreamingStates.keys(), - ...this.abortControllers.keys(), - ...this.processingStates.keys() - ]).size; - } - - private getMessageByIdWithRole( - messageId: string, - expectedRole?: MessageRole - ): { message: DatabaseMessage; index: number } | null { - const index = conversationsStore.findMessageIndex(messageId); - if (index === -1) return null; - const message = conversationsStore.activeMessages[index]; - if (expectedRole && message.role !== expectedRole) return null; - return { message, index }; - } - - async addMessage( - role: MessageRole, - content: string, - type: MessageType = MessageType.TEXT, - parent: string = '-1', - extras?: DatabaseMessageExtra[] - ): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv) throw new Error('No active conversation'); - let parentId: string | null = null; - if (parent === '-1') { - const am = conversationsStore.activeMessages; - if (am.length > 0) parentId = am[am.length - 1].id; - else { - const all = await conversationsStore.getConversationMessages(activeConv.id); - const r = all.find((m) => m.parent === null && m.type === 'root'); - parentId = r ? r.id : await DatabaseService.createRootMessage(activeConv.id); - } - } else parentId = parent; - const message = await DatabaseService.createMessageBranch( - { - convId: activeConv.id, - role, - content, - type, - timestamp: Date.now(), - toolCalls: '', - children: [], - extra: extras - }, - parentId - ); - conversationsStore.addMessageToActive(message); - await conversationsStore.updateCurrentNode(message.id); - conversationsStore.updateConversationTimestamp(); - return message; - } - - async addSystemPrompt(): Promise { - let activeConv = conversationsStore.activeConversation; - if (!activeConv) { - await conversationsStore.createConversation(); - activeConv = conversationsStore.activeConversation; - } - if (!activeConv) return; - try { - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); - const rootId = rootMessage - ? rootMessage.id - : await DatabaseService.createRootMessage(activeConv.id); - const existingSystemMessage = allMessages.find( - (m) => m.role === MessageRole.SYSTEM && m.parent === rootId - ); - if (existingSystemMessage) { - this.pendingEditMessageId = existingSystemMessage.id; - if (!conversationsStore.activeMessages.some((m) => m.id === existingSystemMessage.id)) - conversationsStore.activeMessages.unshift(existingSystemMessage); - return; - } - const am = conversationsStore.activeMessages; - const firstActiveMessage = am.find((m) => m.parent === rootId); - const systemMessage = await DatabaseService.createSystemMessage( - activeConv.id, - SYSTEM_MESSAGE_PLACEHOLDER, - rootId - ); - if (firstActiveMessage) { - await DatabaseService.updateMessage(firstActiveMessage.id, { parent: systemMessage.id }); - await DatabaseService.updateMessage(systemMessage.id, { - children: [firstActiveMessage.id] - }); - const updatedRootChildren = rootMessage - ? rootMessage.children.filter((id: string) => id !== firstActiveMessage.id) - : []; - await DatabaseService.updateMessage(rootId, { - children: [ - ...updatedRootChildren.filter((id: string) => id !== systemMessage.id), - systemMessage.id - ] - }); - const firstMsgIndex = conversationsStore.findMessageIndex(firstActiveMessage.id); - if (firstMsgIndex !== -1) - conversationsStore.updateMessageAtIndex(firstMsgIndex, { parent: systemMessage.id }); - } - conversationsStore.activeMessages.unshift(systemMessage); - this.pendingEditMessageId = systemMessage.id; - conversationsStore.updateConversationTimestamp(); - } catch (error) { - console.error('Failed to add system prompt:', error); - } - } - - async removeSystemPromptPlaceholder(messageId: string): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv) return false; - try { - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const systemMessage = findMessageById(allMessages, messageId); - if (!systemMessage || systemMessage.role !== MessageRole.SYSTEM) return false; - const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); - if (!rootMessage) return false; - if (allMessages.length === 2 && systemMessage.children.length === 0) { - await conversationsStore.deleteConversation(activeConv.id); - return true; - } - for (const childId of systemMessage.children) { - await DatabaseService.updateMessage(childId, { parent: rootMessage.id }); - const childIndex = conversationsStore.findMessageIndex(childId); - if (childIndex !== -1) - conversationsStore.updateMessageAtIndex(childIndex, { parent: rootMessage.id }); - } - await DatabaseService.updateMessage(rootMessage.id, { - children: [ - ...rootMessage.children.filter((id: string) => id !== messageId), - ...systemMessage.children - ] - }); - await DatabaseService.deleteMessage(messageId); - const systemIndex = conversationsStore.findMessageIndex(messageId); - if (systemIndex !== -1) conversationsStore.activeMessages.splice(systemIndex, 1); - conversationsStore.updateConversationTimestamp(); - return false; - } catch (error) { - console.error('Failed to remove system prompt placeholder:', error); - return false; - } - } - - private async createAssistantMessage(parentId?: string): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv) throw new Error('No active conversation'); - return await DatabaseService.createMessageBranch( - { - convId: activeConv.id, - type: MessageType.TEXT, - role: MessageRole.ASSISTANT, - content: '', - timestamp: Date.now(), - toolCalls: '', - children: [], - model: null - }, - parentId || null - ); - } - - async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise { - if (!content.trim() && (!extras || extras.length === 0)) return; - const activeConv = conversationsStore.activeConversation; - - // If agentic loop is running, inject as a steering message instead of starting a new flow - if (activeConv && agenticStore.isRunning(activeConv.id)) { - agenticStore.injectSteeringMessage(activeConv.id, content, extras); - return; - } - - // If non-agentic streaming is active, queue as a pending message to send after completion - if (activeConv && this.isChatLoadingInternal(activeConv.id)) { - this.injectPendingMessage(activeConv.id, content, extras); - return; - } - - // Cancel any in-flight pre-encode request - this.cancelPreEncode(); - - // Consume MCP resource attachments - converts them to extras and clears the live store - const resourceExtras = mcpStore.consumeResourceAttachmentsAsExtras(); - const allExtras = resourceExtras.length > 0 ? [...(extras || []), ...resourceExtras] : extras; - - let isNewConversation = false; - if (!activeConv) { - await conversationsStore.createConversation(); - isNewConversation = true; - } - const currentConv = conversationsStore.activeConversation; - if (!currentConv) return; - this.showErrorDialog(null); - this.setChatLoading(currentConv.id, true); - this.clearChatStreaming(currentConv.id); - try { - let parentIdForUserMessage: string | undefined; - if (isNewConversation) { - const rootId = await DatabaseService.createRootMessage(currentConv.id); - const currentConfig = config(); - const systemPrompt = currentConfig.systemMessage?.toString().trim(); - if (systemPrompt) { - const systemMessage = await DatabaseService.createSystemMessage( - currentConv.id, - systemPrompt, - rootId - ); - conversationsStore.addMessageToActive(systemMessage); - parentIdForUserMessage = systemMessage.id; - } else parentIdForUserMessage = rootId; - } - const userMessage = await this.addMessage( - MessageRole.USER, - content, - MessageType.TEXT, - parentIdForUserMessage ?? '-1', - allExtras - ); - if (isNewConversation && content) - await conversationsStore.updateConversationName( - currentConv.id, - generateConversationTitle(content, Boolean(config().titleGenerationUseFirstLine)) - ); - const assistantMessage = await this.createAssistantMessage(userMessage.id); - conversationsStore.addMessageToActive(assistantMessage); - await this.streamChatCompletion( - conversationsStore.activeMessages.slice(0, -1), - assistantMessage, - undefined, - undefined, - undefined, - config().titleGenerationUseLLM && isNewConversation ? content : undefined - ); - } catch (error) { - if (isAbortError(error)) { - this.setChatLoading(currentConv.id, false); - return; - } - console.error('Failed to send message:', error); - this.setChatLoading(currentConv.id, false); - const dialogType = - error instanceof Error && error.name === 'TimeoutError' - ? ErrorDialogType.TIMEOUT - : ErrorDialogType.SERVER; - const contextInfo = ( - error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } - ).contextInfo; - this.showErrorDialog({ - type: dialogType, - message: error instanceof Error ? error.message : 'Unknown error', - contextInfo - }); - } - } - - private async streamChatCompletion( - allMessages: DatabaseMessage[], - assistantMessage: DatabaseMessage, - onComplete?: (content: string) => Promise, - onError?: (error: Error) => void, - modelOverride?: string | null, - firstUserMessageContent?: string - ): Promise { - let effectiveModel = modelOverride; - - if (isRouterMode() && !effectiveModel) { - const conversationModel = this.getConversationModel(allMessages); - effectiveModel = selectedModelName() || conversationModel; - } - - if (isRouterMode() && effectiveModel) { - if (!modelsStore.getModelProps(effectiveModel)) - await modelsStore.fetchModelProps(effectiveModel); - } - - // Mutable state for the current message being streamed - let currentMessageId = assistantMessage.id; - let streamedContent = ''; - let streamedReasoningContent = ''; - let resolvedModel: string | null = null; - let modelPersisted = false; - const convId = assistantMessage.convId; - - const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => { - if (!modelName) return; - const n = normalizeModelName(modelName); - if (!n || n === resolvedModel) return; - resolvedModel = n; - const idx = conversationsStore.findMessageIndex(currentMessageId); - conversationsStore.updateMessageAtIndex(idx, { model: n }); - if (persistImmediately && !modelPersisted) { - modelPersisted = true; - DatabaseService.updateMessage(currentMessageId, { model: n }).catch(() => { - modelPersisted = false; - resolvedModel = null; - }); - } - }; - - const updateStreamingUI = () => { - this.setChatStreaming(convId, streamedContent, currentMessageId); - const idx = conversationsStore.findMessageIndex(currentMessageId); - conversationsStore.updateMessageAtIndex(idx, { content: streamedContent }); - }; - - const cleanupStreamingState = () => { - this.setStreamingActive(false); - this.setChatLoading(convId, false); - this.clearChatStreaming(convId); - this.setProcessingState(convId, null); - }; - - this.setStreamingActive(true); - this.setActiveProcessingConversation(convId); - const abortController = this.getOrCreateAbortController(convId); - - const streamCallbacks: ChatStreamCallbacks = { - onChunk: (chunk: string) => { - streamedContent += chunk; - updateStreamingUI(); - }, - onReasoningChunk: (chunk: string) => { - streamedReasoningContent += chunk; - // mark streaming state so a stop mid-thinking can persist the partial reasoning - this.setChatStreaming(convId, streamedContent, currentMessageId); - const idx = conversationsStore.findMessageIndex(currentMessageId); - conversationsStore.updateMessageAtIndex(idx, { - reasoningContent: streamedReasoningContent - }); - }, - onToolCallsStreaming: (toolCalls) => { - const idx = conversationsStore.findMessageIndex(currentMessageId); - conversationsStore.updateMessageAtIndex(idx, { toolCalls: JSON.stringify(toolCalls) }); - }, - onAttachments: (messageId: string, extras: DatabaseMessageExtra[]) => { - if (!extras.length) return; - const idx = conversationsStore.findMessageIndex(messageId); - if (idx === -1) return; - const msg = conversationsStore.activeMessages[idx]; - const updatedExtras = [...(msg.extra || []), ...extras]; - conversationsStore.updateMessageAtIndex(idx, { extra: updatedExtras }); - DatabaseService.updateMessage(messageId, { extra: updatedExtras }).catch(console.error); - }, - onModel: (modelName: string) => recordModel(modelName), - onTurnComplete: (intermediateTimings: ChatMessageTimings) => { - // Update the first assistant message with cumulative agentic timings - const idx = conversationsStore.findMessageIndex(assistantMessage.id); - conversationsStore.updateMessageAtIndex(idx, { timings: intermediateTimings }); - }, - onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => { - const tokensPerSecond = - timings?.predicted_ms && timings?.predicted_n - ? (timings.predicted_n / timings.predicted_ms) * 1000 - : 0; - this.updateProcessingStateFromTimings( - { - prompt_n: timings?.prompt_n || 0, - prompt_ms: timings?.prompt_ms, - predicted_n: timings?.predicted_n || 0, - predicted_per_second: tokensPerSecond, - cache_n: timings?.cache_n || 0, - prompt_progress: promptProgress - }, - convId - ); - }, - onAssistantTurnComplete: async ( - content: string, - reasoningContent: string | undefined, - timings: ChatMessageTimings | undefined, - toolCalls: import('$lib/types/api').ApiChatCompletionToolCall[] | undefined - ) => { - const updateData: Record = { - content, - reasoningContent: reasoningContent || undefined, - toolCalls: toolCalls ? JSON.stringify(toolCalls) : '', - timings - }; - if (resolvedModel && !modelPersisted) updateData.model = resolvedModel; - await DatabaseService.updateMessage(currentMessageId, updateData); - const idx = conversationsStore.findMessageIndex(currentMessageId); - const uiUpdate: Partial = { - content, - reasoningContent: reasoningContent || undefined, - toolCalls: toolCalls ? JSON.stringify(toolCalls) : '' - }; - if (timings) uiUpdate.timings = timings; - if (resolvedModel) uiUpdate.model = resolvedModel; - conversationsStore.updateMessageAtIndex(idx, uiUpdate); - await conversationsStore.updateCurrentNode(currentMessageId); - }, - createToolResultMessage: async ( - toolCallId: string, - content: string, - extras?: DatabaseMessageExtra[] - ) => { - const msg = await DatabaseService.createMessageBranch( - { - convId, - type: MessageType.TEXT, - role: MessageRole.TOOL, - content, - toolCallId, - timestamp: Date.now(), - toolCalls: '', - children: [], - extra: extras - }, - currentMessageId - ); - conversationsStore.addMessageToActive(msg); - await conversationsStore.updateCurrentNode(msg.id); - return msg; - }, - createAssistantMessage: async () => { - // Reset streaming state for new message - streamedContent = ''; - streamedReasoningContent = ''; - - const lastMsg = - conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1]; - const msg = await DatabaseService.createMessageBranch( - { - convId, - type: MessageType.TEXT, - role: MessageRole.ASSISTANT, - content: '', - timestamp: Date.now(), - toolCalls: '', - children: [], - model: resolvedModel - }, - lastMsg.id - ); - conversationsStore.addMessageToActive(msg); - currentMessageId = msg.id; - return msg; - }, - onFlowComplete: (finalTimings?: ChatMessageTimings) => { - if (finalTimings) { - const idx = conversationsStore.findMessageIndex(assistantMessage.id); - - conversationsStore.updateMessageAtIndex(idx, { timings: finalTimings }); - DatabaseService.updateMessage(assistantMessage.id, { timings: finalTimings }).catch( - console.error - ); - } - - cleanupStreamingState(); - - if (onComplete) onComplete(streamedContent); - if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error); - // Pre-encode conversation in KV cache for faster next turn - if (config().preEncodeConversation) { - this.triggerPreEncode( - allMessages, - assistantMessage, - streamedContent, - effectiveModel, - !!config().excludeReasoningFromContext - ); - } - }, - onError: async (error: Error) => { - this.setStreamingActive(false); - if (isAbortError(error)) { - cleanupStreamingState(); - // If aborted with a pending message (e.g. "Send immediately"), re-send it - const pending = this.consumePendingMessage(convId); - if (pending) { - this.sendMessage(pending.content, pending.extras); - } - return; - } - console.error('Streaming error:', error); - // keep whatever was streamed so far, the message stays in memory and in DB - await this.savePartialResponseIfNeeded(convId); - cleanupStreamingState(); - this.clearPendingMessage(convId); - const contextInfo = ( - error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } - ).contextInfo; - this.showErrorDialog({ - type: error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER, - message: error.message, - contextInfo - }); - if (onError) onError(error); - } - }; - - const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides; - - { - const agenticResult = await agenticStore.runAgenticFlow({ - conversationId: convId, - messages: allMessages, - options: { ...this.getApiOptions(), ...(effectiveModel ? { model: effectiveModel } : {}) }, - callbacks: streamCallbacks, - signal: abortController.signal, - perChatOverrides - }); - if (agenticResult.handled) { - // Generate LLM based title for new conversations after agentic flow completes - if (firstUserMessageContent) { - await this.generateTitleWithLLM(firstUserMessageContent, streamedContent, convId); - } - // Check if there's a pending steering message to re-send - const pending = agenticStore.consumePendingSteeringMessage(convId); - if (pending) { - await this.sendMessage(pending.content, pending.extras); - } - return; - } - } - - await ChatService.sendMessage( - allMessages, - { - ...this.getApiOptions(), - ...(effectiveModel ? { model: effectiveModel } : {}), - stream: true, - onChunk: streamCallbacks.onChunk, - onReasoningChunk: streamCallbacks.onReasoningChunk, - onModel: streamCallbacks.onModel, - onTimings: streamCallbacks.onTimings, - onComplete: async ( - finalContent?: string, - reasoningContent?: string, - timings?: ChatMessageTimings, - toolCalls?: string - ) => { - const content = streamedContent || finalContent || ''; - const reasoning = streamedReasoningContent || reasoningContent; - const updateData: Record = { - content, - reasoningContent: reasoning || undefined, - toolCalls: toolCalls || '', - timings - }; - if (resolvedModel && !modelPersisted) updateData.model = resolvedModel; - await DatabaseService.updateMessage(currentMessageId, updateData); - const idx = conversationsStore.findMessageIndex(currentMessageId); - const uiUpdate: Partial = { - content, - reasoningContent: reasoning || undefined, - toolCalls: toolCalls || '' - }; - if (timings) uiUpdate.timings = timings; - if (resolvedModel) uiUpdate.model = resolvedModel; - conversationsStore.updateMessageAtIndex(idx, uiUpdate); - await conversationsStore.updateCurrentNode(currentMessageId); - cleanupStreamingState(); - if (onComplete) await onComplete(content); - if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error); - - // Generate LLM based title for new conversations (avoids stale reference - // issue when user switches conversations while streaming) - if (firstUserMessageContent) { - await this.generateTitleWithLLM(firstUserMessageContent, streamedContent, convId); - } - - // Check if there's a pending message queued during streaming - const pending = this.consumePendingMessage(convId); - if (pending) { - await this.sendMessage(pending.content, pending.extras); - } - }, - onError: streamCallbacks.onError - }, - convId, - abortController.signal - ); - } - - async stopGeneration(): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv) return; - await this.stopGenerationForChat(activeConv.id); - } - async stopGenerationForChat(convId: string): Promise { - await this.savePartialResponseIfNeeded(convId); - this.setStreamingActive(false); - this.abortRequest(convId); - this.setChatLoading(convId, false); - this.clearChatStreaming(convId); - this.setProcessingState(convId, null); - this.clearPendingMessage(convId); - } - - private async generateTitleWithLLM( - userContent: string, - assistantContent: string, - convId: string - ): Promise { - const effectiveModel = isRouterMode() && selectedModelName() ? selectedModelName() : undefined; - const configValue = config(); - const titlePromptTemplate = - typeof configValue.titleGenerationPrompt === 'string' && - configValue.titleGenerationPrompt.trim() - ? configValue.titleGenerationPrompt - : TITLE_GENERATION.DEFAULT_PROMPT; - - const titlePrompt = titlePromptTemplate - .replace('{{USER}}', String(userContent || '')) - .replace('{{ASSISTANT}}', String(assistantContent || '')); - - const titleMessage: ApiChatMessageData = { - role: MessageRole.USER, - content: titlePrompt - }; - - const titleResponse = await ChatService.generateTitle(titleMessage, effectiveModel); - - if (!titleResponse) { - return; - } - - let cleanTitle = titleResponse.trim(); - cleanTitle = cleanTitle - .replace(TITLE_GENERATION.PREFIX_PATTERN, '') - .replace(TITLE_GENERATION.QUOTE_PATTERN, '') - .trim(); - if (!cleanTitle || cleanTitle.length < TITLE_GENERATION.MIN_LENGTH) { - const firstLine = userContent.split('\n').find((l) => l.trim().length > 0); - cleanTitle = firstLine ? firstLine.trim() : TITLE_GENERATION.FALLBACK; - } - if (cleanTitle && cleanTitle.length >= TITLE_GENERATION.MIN_LENGTH) { - await conversationsStore.updateConversationName(convId, cleanTitle); - } - } - - private async savePartialResponseIfNeeded(convId?: string): Promise { - const conversationId = convId || conversationsStore.activeConversation?.id; - if (!conversationId) return; - const streamingState = this.getChatStreaming(conversationId); - if (!streamingState) return; - const messages = - conversationId === conversationsStore.activeConversation?.id - ? conversationsStore.activeMessages - : await conversationsStore.getConversationMessages(conversationId); - if (!messages.length) return; - const lastMessage = messages[messages.length - 1]; - if (lastMessage?.role !== MessageRole.ASSISTANT) return; - - const partialContent = streamingState.response; - const partialReasoning = lastMessage.reasoningContent || ''; - - // nothing to persist when both content and reasoning are empty (e.g. stop before any token) - if (!partialContent.trim() && !partialReasoning.trim()) return; - - try { - const updateData: { - content: string; - reasoningContent?: string; - timings?: ChatMessageTimings; - } = { - content: partialContent - }; - if (partialReasoning) { - updateData.reasoningContent = partialReasoning; - } - const lastKnownState = this.getProcessingState(conversationId); - if (lastKnownState) { - updateData.timings = { - prompt_n: lastKnownState.promptTokens || 0, - prompt_ms: lastKnownState.promptMs, - predicted_n: lastKnownState.tokensDecoded || 0, - cache_n: lastKnownState.cacheTokens || 0, - predicted_ms: - lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded - ? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000 - : undefined - }; - } - await DatabaseService.updateMessage(lastMessage.id, updateData); - lastMessage.content = partialContent; - if (updateData.timings) lastMessage.timings = updateData.timings; - } catch (error) { - lastMessage.content = partialContent; - console.error('Failed to save partial response:', error); - } - } - - async updateMessage(messageId: string, newContent: string): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv) return; - if (this.isChatLoadingInternal(activeConv.id)) await this.stopGeneration(); - const result = this.getMessageByIdWithRole(messageId, MessageRole.USER); - if (!result) return; - const { message: messageToUpdate, index: messageIndex } = result; - const originalContent = messageToUpdate.content; - try { - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); - const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id; - conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent }); - await DatabaseService.updateMessage(messageId, { content: newContent }); - if (isFirstUserMessage && newContent.trim()) - await conversationsStore.updateConversationTitleWithConfirmation( - activeConv.id, - generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine)) - ); - const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1); - for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id); - conversationsStore.sliceActiveMessages(messageIndex + 1); - conversationsStore.updateConversationTimestamp(); - this.setChatLoading(activeConv.id, true); - this.clearChatStreaming(activeConv.id); - const assistantMessage = await this.createAssistantMessage(); - conversationsStore.addMessageToActive(assistantMessage); - await conversationsStore.updateCurrentNode(assistantMessage.id); - await this.streamChatCompletion( - conversationsStore.activeMessages.slice(0, -1), - assistantMessage, - undefined, - () => { - conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(messageId), { - content: originalContent - }); - } - ); - } catch (error) { - if (!isAbortError(error)) console.error('Failed to update message:', error); - } - } - - async regenerateMessage(messageId: string): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; - this.cancelPreEncode(); - const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT); - if (!result) return; - const { index: messageIndex } = result; - try { - const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex); - for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id); - conversationsStore.sliceActiveMessages(messageIndex); - conversationsStore.updateConversationTimestamp(); - this.setChatLoading(activeConv.id, true); - this.clearChatStreaming(activeConv.id); - const parentMessageId = - conversationsStore.activeMessages.length > 0 - ? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id - : undefined; - const assistantMessage = await this.createAssistantMessage(parentMessageId); - conversationsStore.addMessageToActive(assistantMessage); - await this.streamChatCompletion( - conversationsStore.activeMessages.slice(0, -1), - assistantMessage - ); - } catch (error) { - if (!isAbortError(error)) console.error('Failed to regenerate message:', error); - this.setChatLoading(activeConv?.id || '', false); - } - } - - async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; - this.cancelPreEncode(); - try { - const idx = conversationsStore.findMessageIndex(messageId); - if (idx === -1) return; - const msg = conversationsStore.activeMessages[idx]; - if (msg.role !== MessageRole.ASSISTANT) return; - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const parentMessage = findMessageById(allMessages, msg.parent); - if (!parentMessage) return; - this.setChatLoading(activeConv.id, true); - this.clearChatStreaming(activeConv.id); - const newAssistantMessage = await DatabaseService.createMessageBranch( - { - convId: msg.convId, - type: msg.type, - timestamp: Date.now(), - role: msg.role, - content: '', - toolCalls: '', - children: [], - model: null - }, - parentMessage.id - ); - await conversationsStore.updateCurrentNode(newAssistantMessage.id); - conversationsStore.updateConversationTimestamp(); - await conversationsStore.refreshActiveMessages(); - const conversationPath = filterByLeafNodeId( - allMessages, - parentMessage.id, - false - ) as DatabaseMessage[]; - const modelToUse = modelOverride || msg.model || undefined; - await this.streamChatCompletion( - conversationPath, - newAssistantMessage, - undefined, - undefined, - modelToUse - ); - } catch (error) { - if (!isAbortError(error)) - console.error('Failed to regenerate message with branching:', error); - this.setChatLoading(activeConv?.id || '', false); - } - } - - async getDeletionInfo(messageId: string): Promise<{ - totalCount: number; - userMessages: number; - assistantMessages: number; - messageTypes: string[]; - }> { - const activeConv = conversationsStore.activeConversation; - if (!activeConv) - return { totalCount: 0, userMessages: 0, assistantMessages: 0, messageTypes: [] }; - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const messageToDelete = findMessageById(allMessages, messageId); - - // For system messages, don't count descendants as they will be preserved (reparented to root) - if (messageToDelete?.role === MessageRole.SYSTEM) { - const messagesToDelete = allMessages.filter((m) => m.id === messageId); - let userMessages = 0, - assistantMessages = 0; - const messageTypes: string[] = []; - - for (const msg of messagesToDelete) { - if (msg.role === MessageRole.USER) { - userMessages++; - if (!messageTypes.includes('user message')) messageTypes.push('user message'); - } else if (msg.role === MessageRole.ASSISTANT) { - assistantMessages++; - if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response'); - } - } - - return { totalCount: 1, userMessages, assistantMessages, messageTypes }; - } - - const descendants = findDescendantMessages(allMessages, messageId); - const allToDelete = [messageId, ...descendants]; - const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id)); - let userMessages = 0, - assistantMessages = 0; - const messageTypes: string[] = []; - - for (const msg of messagesToDelete) { - if (msg.role === MessageRole.USER) { - userMessages++; - if (!messageTypes.includes('user message')) messageTypes.push('user message'); - } else if (msg.role === MessageRole.ASSISTANT) { - assistantMessages++; - if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response'); - } - } - - return { totalCount: allToDelete.length, userMessages, assistantMessages, messageTypes }; - } - - async deleteMessage(messageId: string): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv) return; - try { - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const messageToDelete = findMessageById(allMessages, messageId); - - if (!messageToDelete) return; - - const currentPath = filterByLeafNodeId(allMessages, activeConv.currNode || '', false); - const isInCurrentPath = currentPath.some((m) => m.id === messageId); - - if (isInCurrentPath && messageToDelete.parent) { - const siblings = allMessages.filter( - (m) => m.parent === messageToDelete.parent && m.id !== messageId - ); - - if (siblings.length > 0) { - const latestSibling = siblings.reduce((latest, sibling) => - sibling.timestamp > latest.timestamp ? sibling : latest - ); - - await conversationsStore.updateCurrentNode(findLeafNode(allMessages, latestSibling.id)); - } else if (messageToDelete.parent) { - await conversationsStore.updateCurrentNode( - findLeafNode(allMessages, messageToDelete.parent) - ); - } - } - - await DatabaseService.deleteMessageCascading(activeConv.id, messageId); - await conversationsStore.refreshActiveMessages(); - - conversationsStore.updateConversationTimestamp(); - } catch (error) { - console.error('Failed to delete message:', error); - } - } - - async continueAssistantMessage(messageId: string): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; - const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT); - - if (!result) return; - - const { message: msg, index: idx } = result; - - try { - this.showErrorDialog(null); - this.setChatLoading(activeConv.id, true); - this.clearChatStreaming(activeConv.id); - - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const dbMessage = findMessageById(allMessages, messageId); - - if (!dbMessage) { - this.setChatLoading(activeConv.id, false); - return; - } - - const originalContent = dbMessage.content; - const originalReasoning = dbMessage.reasoningContent || ''; - const conversationContext = conversationsStore.activeMessages.slice(0, idx); - const contextWithContinue = [ - ...conversationContext, - { - role: MessageRole.ASSISTANT as const, - content: originalContent, - reasoning_content: originalReasoning || undefined - } - ]; - - let appendedContent = ''; - let appendedReasoning = ''; - let hasReceivedContent = false; - - const updateStreamingContent = (fullContent: string) => { - this.setChatStreaming(msg.convId, fullContent, msg.id); - conversationsStore.updateMessageAtIndex(idx, { content: fullContent }); - }; - - const abortController = this.getOrCreateAbortController(msg.convId); - - await ChatService.sendMessage( - contextWithContinue, - { - ...this.getApiOptions(), - continueFinalMessage: true, - onChunk: (chunk: string) => { - appendedContent += chunk; - hasReceivedContent = true; - updateStreamingContent(originalContent + appendedContent); - }, - onReasoningChunk: (chunk: string) => { - appendedReasoning += chunk; - hasReceivedContent = true; - // mark streaming state so a stop mid-thinking can persist the partial reasoning - this.setChatStreaming(msg.convId, originalContent + appendedContent, msg.id); - conversationsStore.updateMessageAtIndex(idx, { - reasoningContent: originalReasoning + appendedReasoning - }); - }, - onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => { - const tokensPerSecond = - timings?.predicted_ms && timings?.predicted_n - ? (timings.predicted_n / timings.predicted_ms) * 1000 - : 0; - this.updateProcessingStateFromTimings( - { - prompt_n: timings?.prompt_n || 0, - prompt_ms: timings?.prompt_ms, - predicted_n: timings?.predicted_n || 0, - predicted_per_second: tokensPerSecond, - cache_n: timings?.cache_n || 0, - prompt_progress: promptProgress - }, - msg.convId - ); - }, - onComplete: async ( - finalContent?: string, - reasoningContent?: string, - timings?: ChatMessageTimings - ) => { - const finalAppendedContent = hasReceivedContent ? appendedContent : finalContent || ''; - const finalAppendedReasoning = hasReceivedContent - ? appendedReasoning - : reasoningContent || ''; - const fullContent = originalContent + finalAppendedContent; - const fullReasoning = originalReasoning + finalAppendedReasoning || undefined; - - await DatabaseService.updateMessage(msg.id, { - content: fullContent, - reasoningContent: fullReasoning, - timestamp: Date.now(), - timings - }); - - conversationsStore.updateMessageAtIndex(idx, { - content: fullContent, - reasoningContent: fullReasoning, - timestamp: Date.now(), - timings - }); - - conversationsStore.updateConversationTimestamp(); - - this.setChatLoading(msg.convId, false); - this.clearChatStreaming(msg.convId); - this.setProcessingState(msg.convId, null); - }, - onError: async (error: Error) => { - if (isAbortError(error)) { - if (hasReceivedContent && appendedContent) { - await DatabaseService.updateMessage(msg.id, { - content: originalContent + appendedContent, - reasoningContent: originalReasoning + appendedReasoning || undefined, - timestamp: Date.now() - }); - - conversationsStore.updateMessageAtIndex(idx, { - content: originalContent + appendedContent, - reasoningContent: originalReasoning + appendedReasoning || undefined, - timestamp: Date.now() - }); - } - - this.setChatLoading(msg.convId, false); - this.clearChatStreaming(msg.convId); - this.setProcessingState(msg.convId, null); - - return; - } - - console.error('Continue generation error:', error); - // keep whatever was appended so far, the message stays in memory and in DB - await DatabaseService.updateMessage(msg.id, { - content: originalContent + appendedContent, - reasoningContent: originalReasoning + appendedReasoning || undefined, - timestamp: Date.now() - }); - conversationsStore.updateMessageAtIndex(idx, { - content: originalContent + appendedContent, - reasoningContent: originalReasoning + appendedReasoning || undefined, - timestamp: Date.now() - }); - - this.setChatLoading(msg.convId, false); - this.clearChatStreaming(msg.convId); - this.setProcessingState(msg.convId, null); - this.showErrorDialog({ - type: - error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER, - message: error.message - }); - } - }, - - msg.convId, - abortController.signal - ); - } catch (error) { - if (!isAbortError(error)) console.error('Failed to continue message:', error); - if (activeConv) this.setChatLoading(activeConv.id, false); - } - } - - async editAssistantMessage( - messageId: string, - newContent: string, - shouldBranch: boolean - ): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; - - const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT); - if (!result) return; - - const { message: msg, index: idx } = result; - - try { - if (shouldBranch) { - const newMessage = await DatabaseService.createMessageBranch( - { - convId: msg.convId, - type: msg.type, - timestamp: Date.now(), - role: msg.role, - content: newContent, - toolCalls: msg.toolCalls || '', - children: [], - model: msg.model - }, - msg.parent! - ); - - await conversationsStore.updateCurrentNode(newMessage.id); - } else { - await DatabaseService.updateMessage(msg.id, { content: newContent }); - conversationsStore.updateMessageAtIndex(idx, { content: newContent }); - } - - conversationsStore.updateConversationTimestamp(); - - await conversationsStore.refreshActiveMessages(); - } catch (error) { - console.error('Failed to edit assistant message:', error); - } - } - - async editUserMessagePreserveResponses( - messageId: string, - newContent: string, - newExtras?: DatabaseMessageExtra[] - ): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv) return; - - const result = this.getMessageByIdWithRole(messageId, MessageRole.USER); - if (!result) return; - - const { message: msg, index: idx } = result; - try { - const updateData: Partial = { content: newContent }; - - if (newExtras !== undefined) updateData.extra = JSON.parse(JSON.stringify(newExtras)); - - await DatabaseService.updateMessage(messageId, updateData); - - conversationsStore.updateMessageAtIndex(idx, updateData); - - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); - - if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) { - await conversationsStore.updateConversationTitleWithConfirmation( - activeConv.id, - generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine)) - ); - } - - conversationsStore.updateConversationTimestamp(); - } catch (error) { - console.error('Failed to edit user message:', error); - } - } - - async editMessageWithBranching( - messageId: string, - newContent: string, - newExtras?: DatabaseMessageExtra[] - ): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; - let result = this.getMessageByIdWithRole(messageId, MessageRole.USER); - if (!result) result = this.getMessageByIdWithRole(messageId, MessageRole.SYSTEM); - if (!result) return; - const { message: msg, index: idx } = result; - try { - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); - const isFirstUserMessage = - msg.role === MessageRole.USER && rootMessage && msg.parent === rootMessage.id; - const extrasToUse = - newExtras !== undefined - ? JSON.parse(JSON.stringify(newExtras)) - : msg.extra - ? JSON.parse(JSON.stringify(msg.extra)) - : undefined; - - let messageIdForResponse: string; - - const dbMsg = findMessageById(allMessages, msg.id); - const hasChildren = dbMsg ? dbMsg.children.length > 0 : msg.children.length > 0; - - if (!hasChildren) { - // No responses after this message — update in place instead of branching - const updates: Partial = { - content: newContent, - timestamp: Date.now(), - extra: extrasToUse - }; - await DatabaseService.updateMessage(msg.id, updates); - conversationsStore.updateMessageAtIndex(idx, updates); - messageIdForResponse = msg.id; - } else { - // Has children — create a new branch as sibling - const parentId = msg.parent || rootMessage?.id; - if (!parentId) return; - const newMessage = await DatabaseService.createMessageBranch( - { - convId: msg.convId, - type: msg.type, - timestamp: Date.now(), - role: msg.role, - content: newContent, - toolCalls: msg.toolCalls || '', - children: [], - extra: extrasToUse, - model: msg.model - }, - parentId - ); - await conversationsStore.updateCurrentNode(newMessage.id); - messageIdForResponse = newMessage.id; - } - - conversationsStore.updateConversationTimestamp(); - if (isFirstUserMessage && newContent.trim()) - await conversationsStore.updateConversationTitleWithConfirmation( - activeConv.id, - generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine)) - ); - await conversationsStore.refreshActiveMessages(); - if (msg.role === MessageRole.USER) - await this.generateResponseForMessage(messageIdForResponse); - } catch (error) { - console.error('Failed to edit message with branching:', error); - } - } - - private async generateResponseForMessage(userMessageId: string): Promise { - const activeConv = conversationsStore.activeConversation; - if (!activeConv) return; - - this.showErrorDialog(null); - this.setChatLoading(activeConv.id, true); - this.clearChatStreaming(activeConv.id); - - try { - const allMessages = await conversationsStore.getConversationMessages(activeConv.id); - const conversationPath = filterByLeafNodeId( - allMessages, - userMessageId, - false - ) as DatabaseMessage[]; - const assistantMessage = await DatabaseService.createMessageBranch( - { - convId: activeConv.id, - type: MessageType.TEXT, - timestamp: Date.now(), - role: MessageRole.ASSISTANT, - content: '', - toolCalls: '', - children: [], - model: null - }, - userMessageId - ); - - conversationsStore.addMessageToActive(assistantMessage); - - await this.streamChatCompletion(conversationPath, assistantMessage); - } catch (error) { - console.error('Failed to generate response:', error); - this.setChatLoading(activeConv.id, false); - } - } - - private getContextTotal(): number | null { - const activeConvId = this.activeConversationId; - const activeState = activeConvId ? this.getProcessingState(activeConvId) : null; - - if (activeState && typeof activeState.contextTotal === 'number' && activeState.contextTotal > 0) - return activeState.contextTotal; - - if (isRouterMode()) { - const modelContextSize = selectedModelContextSize(); - - if (typeof modelContextSize === 'number' && modelContextSize > 0) { - return modelContextSize; - } - } else { - const propsContextSize = contextSize(); - - if (typeof propsContextSize === 'number' && propsContextSize > 0) { - return propsContextSize; - } - } - - return null; - } - - updateProcessingStateFromTimings( - timingData: { - prompt_n: number; - prompt_ms?: number; - predicted_n: number; - predicted_per_second: number; - cache_n: number; - prompt_progress?: ChatMessagePromptProgress; - }, - conversationId?: string - ): void { - const processingState = this.parseTimingData(timingData); - - if (processingState === null) { - console.warn('Failed to parse timing data - skipping update'); - return; - } - - const targetId = conversationId || this.activeConversationId; - if (targetId) { - this.setProcessingState(targetId, processingState); - } - } - - private parseTimingData(timingData: Record): ApiProcessingState | null { - const promptTokens = (timingData.prompt_n as number) || 0, - promptMs = (timingData.prompt_ms as number) || undefined, - predictedTokens = (timingData.predicted_n as number) || 0, - tokensPerSecond = (timingData.predicted_per_second as number) || 0, - cacheTokens = (timingData.cache_n as number) || 0; - const promptProgress = timingData.prompt_progress as - | { total: number; cache: number; processed: number; time_ms: number } - | undefined; - const contextTotal = this.getContextTotal(); - const currentConfig = config(); - const outputTokensMax = currentConfig.max_tokens || -1; - const contextUsed = promptTokens + cacheTokens + predictedTokens, - outputTokensUsed = predictedTokens; - const progressCache = promptProgress?.cache || 0, - progressActualDone = (promptProgress?.processed ?? 0) - progressCache, - progressActualTotal = (promptProgress?.total ?? 0) - progressCache; - const progressPercent = promptProgress - ? Math.round((progressActualDone / progressActualTotal) * 100) - : undefined; - return { - status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle', - tokensDecoded: predictedTokens, - tokensRemaining: outputTokensMax - predictedTokens, - contextUsed, - contextTotal, - outputTokensUsed, - outputTokensMax, - hasNextToken: predictedTokens > 0, - tokensPerSecond, - temperature: currentConfig.temperature ?? 0.8, - topP: currentConfig.top_p ?? 0.95, - speculative: false, - progressPercent, - promptProgress, - promptTokens, - promptMs, - cacheTokens - }; - } - - restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (message.role === MessageRole.ASSISTANT && message.timings) { - const restoredState = this.parseTimingData({ - prompt_n: message.timings.prompt_n || 0, - prompt_ms: message.timings.prompt_ms, - predicted_n: message.timings.predicted_n || 0, - predicted_per_second: - message.timings.predicted_n && message.timings.predicted_ms - ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000 - : 0, - cache_n: message.timings.cache_n || 0 - }); - if (restoredState) { - this.setProcessingState(conversationId, restoredState); - return; - } - } - } - } - - getConversationModel(messages: DatabaseMessage[]): string | null { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (message.role === MessageRole.ASSISTANT && message.model) return message.model; - } - return null; - } - - private getApiOptions(): Record { - const currentConfig = config(); - const hasValue = (value: unknown): boolean => - value !== undefined && value !== null && value !== ''; - const apiOptions: Record = { stream: true, timings_per_token: true }; - - if (isRouterMode()) { - const modelName = selectedModelName(); - if (modelName) apiOptions.model = modelName; - } - - if (currentConfig.systemMessage) apiOptions.systemMessage = currentConfig.systemMessage; - - if (currentConfig.disableReasoningParsing) apiOptions.disableReasoningParsing = true; - - if (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true; - - if (hasValue(currentConfig.temperature)) - apiOptions.temperature = Number(currentConfig.temperature); - - if (hasValue(currentConfig.max_tokens)) - apiOptions.max_tokens = Number(currentConfig.max_tokens); - - if (hasValue(currentConfig.dynatemp_range)) - apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range); - - if (hasValue(currentConfig.dynatemp_exponent)) - apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent); - - if (hasValue(currentConfig.top_k)) apiOptions.top_k = Number(currentConfig.top_k); - - if (hasValue(currentConfig.top_p)) apiOptions.top_p = Number(currentConfig.top_p); - - if (hasValue(currentConfig.min_p)) apiOptions.min_p = Number(currentConfig.min_p); - - if (hasValue(currentConfig.xtc_probability)) - apiOptions.xtc_probability = Number(currentConfig.xtc_probability); - - if (hasValue(currentConfig.xtc_threshold)) - apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold); - - if (hasValue(currentConfig.typ_p)) apiOptions.typ_p = Number(currentConfig.typ_p); - - if (hasValue(currentConfig.repeat_last_n)) - apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n); - - if (hasValue(currentConfig.repeat_penalty)) - apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty); - - if (hasValue(currentConfig.presence_penalty)) - apiOptions.presence_penalty = Number(currentConfig.presence_penalty); - - if (hasValue(currentConfig.frequency_penalty)) - apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty); - - if (hasValue(currentConfig.dry_multiplier)) - apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier); - - if (hasValue(currentConfig.dry_base)) apiOptions.dry_base = Number(currentConfig.dry_base); - - if (hasValue(currentConfig.dry_allowed_length)) - apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length); - - if (hasValue(currentConfig.dry_penalty_last_n)) - apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n); - - if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers; - - apiOptions.backend_sampling = currentConfig.backend_sampling; - - if (currentConfig.custom) apiOptions.custom = currentConfig.custom; - - return apiOptions; - } - - private cancelPreEncode(): void { - if (this.preEncodeAbortController) { - this.preEncodeAbortController.abort(); - this.preEncodeAbortController = null; - } - } - - private async triggerPreEncode( - allMessages: DatabaseMessage[], - assistantMessage: DatabaseMessage, - assistantContent: string, - model?: string | null, - excludeReasoning?: boolean - ): Promise { - this.cancelPreEncode(); - this.preEncodeAbortController = new AbortController(); - - const signal = this.preEncodeAbortController.signal; - - try { - const allIdle = await ChatService.areAllSlotsIdle(model, signal); - if (!allIdle || signal.aborted) return; - - const messagesWithAssistant: DatabaseMessage[] = [ - ...allMessages, - { ...assistantMessage, content: assistantContent } - ]; - - await ChatService.preEncode(messagesWithAssistant, model, excludeReasoning, signal); - } catch (err) { - if (!isAbortError(err)) { - console.warn('[ChatStore] Pre-encode failed:', err); - } - } - } -} - -export const chatStore = new ChatStore(); - -export const activeProcessingState = () => chatStore.activeProcessingState; -export const currentResponse = () => chatStore.currentResponse; -export const errorDialog = () => chatStore.errorDialogState; -export const getAddFilesHandler = () => chatStore.getAddFilesHandler(); -export const getAllLoadingChats = () => chatStore.getAllLoadingChats(); -export const getAllStreamingChats = () => chatStore.getAllStreamingChats(); -export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId); -export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId); -export const isChatStreaming = () => chatStore.isStreaming(); -export const isEditing = () => chatStore.isEditing(); -export const isLoading = () => chatStore.isLoading; -export const pendingEditMessageId = () => chatStore.pendingEditMessageId; -export const chatHasPendingMessage = (convId: string) => chatStore.hasPendingMessage(convId); -export const chatPendingMessageContent = (convId: string) => - chatStore.pendingMessageContent(convId); -export const chatPendingMessageExtras = (convId: string) => chatStore.pendingMessageExtras(convId); -export const chatClearPendingMessage = (convId: string) => chatStore.clearPendingMessage(convId); -export const chatInjectPendingMessage = ( - convId: string, - content: string, - extras?: DatabaseMessageExtra[] -) => chatStore.injectPendingMessage(convId, content, extras); diff --git a/tools/server/webui/src/lib/stores/conversations.svelte.ts b/tools/server/webui/src/lib/stores/conversations.svelte.ts deleted file mode 100644 index 087301de7..000000000 --- a/tools/server/webui/src/lib/stores/conversations.svelte.ts +++ /dev/null @@ -1,976 +0,0 @@ -/** - * conversationsStore - Reactive State Store for Conversations - * - * Manages conversation lifecycle, persistence, navigation, and MCP server overrides. - * - * **Architecture & Relationships:** - * - **DatabaseService**: Stateless IndexedDB layer - * - **conversationsStore** (this): Reactive state + business logic - * - **chatStore**: Chat-specific state (streaming, loading) - * - * **Key Responsibilities:** - * - Conversation CRUD (create, load, delete) - * - Message management and tree navigation - * - MCP server per-chat overrides - * - Import/Export functionality - * - Title management with confirmation - * - * @see DatabaseService in services/database.ts for IndexedDB operations - */ - -import { goto } from '$app/navigation'; -import { browser } from '$app/environment'; -import { toast } from 'svelte-sonner'; -import { DatabaseService } from '$lib/services/database.service'; -import { config } from '$lib/stores/settings.svelte'; -import { - filterByLeafNodeId, - findLeafNode, - runLegacyMigration, - generateConversationTitle -} from '$lib/utils'; -import type { McpServerOverride } from '$lib/types/database'; -import { MessageRole, HtmlInputType, FileExtensionText } from '$lib/enums'; -import { - ISO_DATE_TIME_SEPARATOR, - ISO_DATE_TIME_SEPARATOR_REPLACEMENT, - ISO_TIMESTAMP_SLICE_LENGTH, - EXPORT_CONV_ID_TRIM_LENGTH, - EXPORT_CONV_NONALNUM_REPLACEMENT, - EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH, - ISO_TIME_SEPARATOR, - ISO_TIME_SEPARATOR_REPLACEMENT, - NON_ALPHANUMERIC_REGEX, - MULTIPLE_UNDERSCORE_REGEX, - MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY -} from '$lib/constants'; -import { ROUTES } from '$lib/constants/routes'; -import { RouterService } from '$lib/services/router.service'; -import { SvelteMap, SvelteSet } from 'svelte/reactivity'; - -export interface ConversationTreeItem { - conversation: DatabaseConversation; - depth: number; -} - -class ConversationsStore { - /** - * - * - * State - * - * - */ - - /** List of all conversations */ - conversations = $state([]); - - /** Currently active conversation */ - activeConversation = $state(null); - - /** Messages in the active conversation (filtered by currNode path) */ - activeMessages = $state([]); - - /** Whether the store has been initialized */ - isInitialized = $state(false); - - /** Pending MCP server overrides for new conversations (before first message) */ - pendingMcpServerOverrides = $state(ConversationsStore.loadMcpDefaults()); - - /** Load MCP default overrides from localStorage */ - private static loadMcpDefaults(): McpServerOverride[] { - if (typeof globalThis.localStorage === 'undefined') return []; - try { - const raw = localStorage.getItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter( - (o: unknown) => typeof o === 'object' && o !== null && 'serverId' in o && 'enabled' in o - ) as McpServerOverride[]; - } catch { - return []; - } - } - - /** Persist MCP default overrides to localStorage */ - private saveMcpDefaults(): void { - if (typeof globalThis.localStorage === 'undefined') return; - const plain = this.pendingMcpServerOverrides.map((o) => ({ - serverId: o.serverId, - enabled: o.enabled - })); - if (plain.length > 0) { - localStorage.setItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY, JSON.stringify(plain)); - } else { - localStorage.removeItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY); - } - } - - /** Callback for title update confirmation dialog */ - titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise; - - /** - * Callback for updating message content in chatStore. - * Registered by chatStore to enable cross-store updates without circular dependency. - */ - private messageUpdateCallback: - | ((messageId: string, updates: Partial) => void) - | null = null; - - /** - * - * - * Lifecycle - * - * - */ - - /** - * Initialize the store by loading conversations from database. - * Must be called once after app startup. - */ - async init(): Promise { - if (!browser) return; - if (this.isInitialized) return; - - try { - // @deprecated Legacy migration for old marker-based messages. - // Remove once all users have migrated to the structured format. - await runLegacyMigration(); - - await this.loadConversations(); - this.isInitialized = true; - } catch (error) { - console.error('Failed to initialize conversations:', error); - } - } - - /** - * Alias for init() for backward compatibility. - */ - async initialize(): Promise { - return this.init(); - } - - /** - * Register a callback for message updates from other stores. - * Called by chatStore during initialization. - */ - registerMessageUpdateCallback( - callback: (messageId: string, updates: Partial) => void - ): void { - this.messageUpdateCallback = callback; - } - - /** - * - * - * Message Array Operations - * - * - */ - - /** - * Adds a message to the active messages array - */ - addMessageToActive(message: DatabaseMessage): void { - this.activeMessages.push(message); - } - - /** - * Updates a message at a specific index in active messages - */ - updateMessageAtIndex(index: number, updates: Partial): void { - if (index !== -1 && this.activeMessages[index]) { - this.activeMessages[index] = { ...this.activeMessages[index], ...updates }; - } - } - - /** - * Finds the index of a message in active messages - */ - findMessageIndex(messageId: string): number { - return this.activeMessages.findIndex((m) => m.id === messageId); - } - - /** - * Removes messages from active messages starting at an index - */ - sliceActiveMessages(startIndex: number): void { - this.activeMessages = this.activeMessages.slice(0, startIndex); - } - - /** - * Removes a message from active messages by index - */ - removeMessageAtIndex(index: number): DatabaseMessage | undefined { - if (index !== -1) { - return this.activeMessages.splice(index, 1)[0]; - } - return undefined; - } - - /** - * Sets the callback function for title update confirmations - */ - setTitleUpdateConfirmationCallback( - callback: (currentTitle: string, newTitle: string) => Promise - ): void { - this.titleUpdateConfirmationCallback = callback; - } - - /** - * - * - * Conversation CRUD - * - * - */ - - /** - * Loads all conversations from the database - */ - async loadConversations(): Promise { - const conversations = await DatabaseService.getAllConversations(); - this.conversations = conversations; - } - - /** - * Creates a new conversation and navigates to it - * @param name - Optional name for the conversation - * @returns The ID of the created conversation - */ - async createConversation(name?: string): Promise { - const conversationName = name || `Chat ${new Date().toLocaleString()}`; - const conversation = await DatabaseService.createConversation(conversationName); - - if (this.pendingMcpServerOverrides.length > 0) { - // Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB) - const plainOverrides = this.pendingMcpServerOverrides.map((o) => ({ - serverId: o.serverId, - enabled: o.enabled - })); - conversation.mcpServerOverrides = plainOverrides; - await DatabaseService.updateConversation(conversation.id, { - mcpServerOverrides: plainOverrides - }); - this.pendingMcpServerOverrides = []; - } - - this.conversations = [conversation, ...this.conversations]; - this.activeConversation = conversation; - this.activeMessages = []; - - await goto(RouterService.chat(conversation.id)); - - return conversation.id; - } - - /** - * Loads a specific conversation and its messages - * @param convId - The conversation ID to load - * @returns True if conversation was loaded successfully - */ - async loadConversation(convId: string): Promise { - try { - const conversation = await DatabaseService.getConversation(convId); - - if (!conversation) { - return false; - } - - this.pendingMcpServerOverrides = []; - this.activeConversation = conversation; - - if (conversation.currNode) { - const allMessages = await DatabaseService.getConversationMessages(convId); - const filteredMessages = filterByLeafNodeId( - allMessages, - conversation.currNode, - false - ) as DatabaseMessage[]; - this.activeMessages = filteredMessages; - } else { - const messages = await DatabaseService.getConversationMessages(convId); - this.activeMessages = messages; - } - - return true; - } catch (error) { - console.error('Failed to load conversation:', error); - return false; - } - } - - /** - * Clears the active conversation and messages. - */ - clearActiveConversation(): void { - this.activeConversation = null; - this.activeMessages = []; - // reload MCP defaults so new chats inherit persisted state - this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults(); - } - - /** - * Deletes a conversation and all its messages - * @param convId - The conversation ID to delete - */ - async deleteConversation(convId: string, options?: { deleteWithForks?: boolean }): Promise { - try { - await DatabaseService.deleteConversation(convId, options); - - if (options?.deleteWithForks) { - // Collect all descendants recursively - const idsToRemove = new SvelteSet([convId]); - const queue = [convId]; - while (queue.length > 0) { - const parentId = queue.pop()!; - for (const c of this.conversations) { - if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) { - idsToRemove.add(c.id); - queue.push(c.id); - } - } - } - this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id)); - - if (this.activeConversation && idsToRemove.has(this.activeConversation.id)) { - this.clearActiveConversation(); - await goto(ROUTES.NEW_CHAT); - } - } else { - // Reparent direct children to deleted conv's parent (or promote to top-level) - const deletedConv = this.conversations.find((c) => c.id === convId); - const newParent = deletedConv?.forkedFromConversationId; - this.conversations = this.conversations - .filter((c) => c.id !== convId) - .map((c) => - c.forkedFromConversationId === convId - ? { ...c, forkedFromConversationId: newParent } - : c - ); - - if (this.activeConversation?.id === convId) { - this.clearActiveConversation(); - await goto(ROUTES.NEW_CHAT); - } - } - } catch (error) { - console.error('Failed to delete conversation:', error); - } - } - - /** - * Deletes all conversations and their messages - */ - async deleteAll(): Promise { - try { - const allConversations = await DatabaseService.getAllConversations(); - - for (const conv of allConversations) { - await DatabaseService.deleteConversation(conv.id); - } - - this.clearActiveConversation(); - this.conversations = []; - - toast.success('All conversations deleted'); - - await goto(ROUTES.NEW_CHAT); - } catch (error) { - console.error('Failed to delete all conversations:', error); - toast.error('Failed to delete conversations'); - } - } - - /** - * - * - * Message Management - * - * - */ - - /** - * Refreshes active messages based on currNode after branch navigation. - */ - async refreshActiveMessages(): Promise { - if (!this.activeConversation) return; - - const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id); - - if (allMessages.length === 0) { - this.activeMessages = []; - return; - } - - const leafNodeId = - this.activeConversation.currNode || - allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id; - - const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[]; - - this.activeMessages = currentPath; - } - - /** - * Gets all messages for a specific conversation - * @param convId - The conversation ID - * @returns Array of messages - */ - async getConversationMessages(convId: string): Promise { - return await DatabaseService.getConversationMessages(convId); - } - - /** - * - * - * Title Management - * - * - */ - - /** - * Updates the name of a conversation. - * @param convId - The conversation ID to update - * @param name - The new name for the conversation - */ - async updateConversationName(convId: string, name: string): Promise { - try { - await DatabaseService.updateConversation(convId, { name }); - - const convIndex = this.conversations.findIndex((c) => c.id === convId); - - if (convIndex !== -1) { - this.conversations[convIndex].name = name; - this.conversations = [...this.conversations]; - } - - if (this.activeConversation?.id === convId) { - this.activeConversation = { ...this.activeConversation, name }; - } - } catch (error) { - console.error('Failed to update conversation name:', error); - } - } - - /** - * Updates conversation title with optional confirmation dialog based on settings - * @param convId - The conversation ID to update - * @param newTitle - The new title content - * @returns True if title was updated, false if cancelled - */ - async updateConversationTitleWithConfirmation( - convId: string, - newTitle: string - ): Promise { - try { - const currentConfig = config(); - - if (currentConfig.askForTitleConfirmation && this.titleUpdateConfirmationCallback) { - const conversation = await DatabaseService.getConversation(convId); - if (!conversation) return false; - - const shouldUpdate = await this.titleUpdateConfirmationCallback( - conversation.name, - newTitle - ); - if (!shouldUpdate) return false; - } - - await this.updateConversationName(convId, newTitle); - return true; - } catch (error) { - console.error('Failed to update conversation title with confirmation:', error); - return false; - } - } - - /** - * Updates conversation lastModified timestamp and moves it to top of list - */ - updateConversationTimestamp(): void { - if (!this.activeConversation) return; - - const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); - - if (chatIndex !== -1) { - this.conversations[chatIndex].lastModified = Date.now(); - const updatedConv = this.conversations.splice(chatIndex, 1)[0]; - this.conversations = [updatedConv, ...this.conversations]; - } - } - - /** - * Updates the current node of the active conversation - * @param nodeId - The new current node ID - */ - async updateCurrentNode(nodeId: string): Promise { - if (!this.activeConversation) return; - - await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId); - this.activeConversation = { ...this.activeConversation, currNode: nodeId }; - } - - /** - * - * - * Branch Navigation - * - * - */ - - /** - * Navigates to a specific sibling branch by updating currNode and refreshing messages. - * @param siblingId - The sibling message ID to navigate to - */ - async navigateToSibling(siblingId: string): Promise { - if (!this.activeConversation) return; - - const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id); - const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); - const currentFirstUserMessage = this.activeMessages.find( - (m) => m.role === MessageRole.USER && m.parent === rootMessage?.id - ); - - const currentLeafNodeId = findLeafNode(allMessages, siblingId); - - await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId); - this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId }; - await this.refreshActiveMessages(); - - if (rootMessage && this.activeMessages.length > 0) { - const newFirstUserMessage = this.activeMessages.find( - (m) => m.role === MessageRole.USER && m.parent === rootMessage.id - ); - - if ( - newFirstUserMessage && - newFirstUserMessage.content.trim() && - (!currentFirstUserMessage || - newFirstUserMessage.id !== currentFirstUserMessage.id || - newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim()) - ) { - await this.updateConversationTitleWithConfirmation( - this.activeConversation.id, - generateConversationTitle( - newFirstUserMessage.content, - Boolean(config().titleGenerationUseFirstLine) - ) - ); - } - } - } - - /** - * - * - * MCP Server Overrides - * - * - */ - - /** - * Gets MCP server override for a specific server in the active conversation. - * Falls back to pending overrides if no active conversation exists. - * @param serverId - The server ID to check - * @returns The override if set, undefined if using global setting - */ - getMcpServerOverride(serverId: string): McpServerOverride | undefined { - if (this.activeConversation) { - return this.activeConversation.mcpServerOverrides?.find( - (o: McpServerOverride) => o.serverId === serverId - ); - } - return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId); - } - - /** - * Get all MCP server overrides for the current conversation. - * Returns pending overrides if no active conversation. - */ - getAllMcpServerOverrides(): McpServerOverride[] { - if (this.activeConversation?.mcpServerOverrides) { - return this.activeConversation.mcpServerOverrides; - } - return this.pendingMcpServerOverrides; - } - - /** - * Checks if an MCP server is enabled for the active conversation. - * @param serverId - The server ID to check - * @returns True if server is enabled for this conversation - */ - isMcpServerEnabledForChat(serverId: string): boolean { - const override = this.getMcpServerOverride(serverId); - return override?.enabled ?? false; - } - - /** - * Sets or removes MCP server override for the active conversation. - * If no conversation exists, stores as pending override. - * @param serverId - The server ID to override - * @param enabled - The enabled state, or undefined to remove override - */ - async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise { - if (!this.activeConversation) { - this.setPendingMcpServerOverride(serverId, enabled); - return; - } - - // Clone to plain objects to avoid Proxy serialization issues with IndexedDB - const currentOverrides = (this.activeConversation.mcpServerOverrides || []).map( - (o: McpServerOverride) => ({ - serverId: o.serverId, - enabled: o.enabled - }) - ); - let newOverrides: McpServerOverride[]; - - if (enabled === undefined) { - newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId); - } else { - const existingIndex = currentOverrides.findIndex( - (o: McpServerOverride) => o.serverId === serverId - ); - if (existingIndex >= 0) { - newOverrides = [...currentOverrides]; - newOverrides[existingIndex] = { serverId, enabled }; - } else { - newOverrides = [...currentOverrides, { serverId, enabled }]; - } - } - - await DatabaseService.updateConversation(this.activeConversation.id, { - mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined - }); - - this.activeConversation = { - ...this.activeConversation, - mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined - }; - - const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); - if (convIndex !== -1) { - this.conversations[convIndex].mcpServerOverrides = - newOverrides.length > 0 ? newOverrides : undefined; - this.conversations = [...this.conversations]; - } - } - - /** - * Sets or removes a pending MCP server override (for new conversations). - */ - private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void { - if (enabled === undefined) { - this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter( - (o) => o.serverId !== serverId - ); - } else { - const existingIndex = this.pendingMcpServerOverrides.findIndex( - (o) => o.serverId === serverId - ); - if (existingIndex >= 0) { - const newOverrides = [...this.pendingMcpServerOverrides]; - newOverrides[existingIndex] = { serverId, enabled }; - this.pendingMcpServerOverrides = newOverrides; - } else { - this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }]; - } - } - this.saveMcpDefaults(); - } - - /** - * Toggles MCP server enabled state for the active conversation. - * @param serverId - The server ID to toggle - */ - async toggleMcpServerForChat(serverId: string): Promise { - const currentEnabled = this.isMcpServerEnabledForChat(serverId); - await this.setMcpServerOverride(serverId, !currentEnabled); - } - - /** - * Removes MCP server override for the active conversation. - * @param serverId - The server ID to remove override for - */ - async removeMcpServerOverride(serverId: string): Promise { - await this.setMcpServerOverride(serverId, undefined); - } - - /** - * Clears all pending MCP server overrides. - */ - clearPendingMcpServerOverrides(): void { - this.pendingMcpServerOverrides = []; - this.saveMcpDefaults(); - } - - /** - * Forks a conversation at a specific message, creating a new conversation - * containing messages from root up to the target message, then navigates to it. - * - * @param messageId - The message ID to fork at - * @param options - Fork options (name and whether to include attachments) - * @returns The new conversation ID, or null if fork failed - */ - async forkConversation( - messageId: string, - options: { name: string; includeAttachments: boolean } - ): Promise { - if (!this.activeConversation) return null; - - try { - const newConv = await DatabaseService.forkConversation( - this.activeConversation.id, - messageId, - options - ); - - this.conversations = [newConv, ...this.conversations]; - - await goto(RouterService.chat(newConv.id)); - - toast.success('Conversation forked'); - - return newConv.id; - } catch (error) { - console.error('Failed to fork conversation:', error); - toast.error('Failed to fork conversation'); - - return null; - } - } - - /** - * - * - * Import & Export - * - * - */ - - /** - * Generates a sanitized filename for a conversation export - * @param conversation - The conversation metadata - * @param msgs - Optional array of messages belonging to the conversation - * @returns The generated filename string - */ - generateConversationFilename( - conversation: { id?: string; name?: string }, - msgs?: DatabaseMessage[] - ): string { - const conversationName = (conversation.name ?? '').trim().toLowerCase(); - - const sanitizedName = conversationName - .replace(NON_ALPHANUMERIC_REGEX, EXPORT_CONV_NONALNUM_REPLACEMENT) - .replace(MULTIPLE_UNDERSCORE_REGEX, '_') - .substring(0, EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH); - - // If we have messages, use the timestamp of the newest message - const referenceDate = msgs?.length - ? new Date(Math.max(...msgs.map((m) => m.timestamp))) - : new Date(); - - const iso = referenceDate.toISOString().slice(0, ISO_TIMESTAMP_SLICE_LENGTH); - const formattedDate = iso - .replace(ISO_DATE_TIME_SEPARATOR, ISO_DATE_TIME_SEPARATOR_REPLACEMENT) - .replaceAll(ISO_TIME_SEPARATOR, ISO_TIME_SEPARATOR_REPLACEMENT); - const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV_ID_TRIM_LENGTH) ?? ''; - return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}.json`; - } - - /** - * Triggers a browser download of the provided exported conversation data - * @param data - The exported conversation payload (either a single conversation or array of them) - * @param filename - Filename; if omitted, a deterministic name is generated - */ - downloadConversationFile(data: ExportedConversations, filename?: string): void { - // Choose the first conversation or message - const conversation = - 'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined; - const msgs = - 'messages' in data ? data.messages : Array.isArray(data) ? data[0]?.messages : undefined; - - if (!conversation) { - console.error('Invalid data: missing conversation'); - return; - } - - let downloadFilename: string; - - if (filename) { - downloadFilename = filename; - } else if (Array.isArray(data) && data.length > 1) { - downloadFilename = `${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations.json`; - } else { - downloadFilename = this.generateConversationFilename(conversation, msgs); - } - - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = downloadFilename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - - /** - * Downloads a conversation as JSON file. - * @param convId - The conversation ID to download - */ - async downloadConversation(convId: string): Promise { - let conversation: DatabaseConversation | null; - let messages: DatabaseMessage[]; - - if (this.activeConversation?.id === convId) { - conversation = this.activeConversation; - messages = this.activeMessages; - } else { - conversation = await DatabaseService.getConversation(convId); - if (!conversation) return; - messages = await DatabaseService.getConversationMessages(convId); - } - - this.downloadConversationFile({ conv: conversation, messages }); - } - - /** - * Imports conversations from a JSON file - * Opens file picker and processes the selected file - * @returns The list of imported conversations - */ - async importConversations(): Promise { - return new Promise((resolve, reject) => { - const input = document.createElement('input'); - input.type = HtmlInputType.FILE; - input.accept = FileExtensionText.JSON; - - input.onchange = async (e) => { - const file = (e.target as HTMLInputElement)?.files?.[0]; - - if (!file) { - reject(new Error('No file selected')); - return; - } - - try { - const text = await file.text(); - const parsedData = JSON.parse(text); - let importedData: ExportedConversations; - - if (Array.isArray(parsedData)) { - importedData = parsedData; - } else if ( - parsedData && - typeof parsedData === 'object' && - 'conv' in parsedData && - 'messages' in parsedData - ) { - importedData = [parsedData]; - } else { - throw new Error('Invalid file format'); - } - - const result = await DatabaseService.importConversations(importedData); - toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`); - - await this.loadConversations(); - - const importedConversations = ( - Array.isArray(importedData) ? importedData : [importedData] - ).map((item) => item.conv); - - resolve(importedConversations); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error'; - console.error('Failed to import conversations:', err); - toast.error('Import failed', { description: message }); - reject(new Error(`Import failed: ${message}`)); - } - }; - - input.click(); - }); - } - - /** - * Imports conversations from provided data (without file picker) - * @param data - Array of conversation data with messages - * @returns Import result with counts - */ - async importConversationsData( - data: ExportedConversations - ): Promise<{ imported: number; skipped: number }> { - const result = await DatabaseService.importConversations(data); - await this.loadConversations(); - return result; - } -} - -export const conversationsStore = new ConversationsStore(); - -// Auto-initialize in browser -if (browser) { - conversationsStore.init(); -} - -export const conversations = () => conversationsStore.conversations; -export const activeConversation = () => conversationsStore.activeConversation; -export const activeMessages = () => conversationsStore.activeMessages; -export const isConversationsInitialized = () => conversationsStore.isInitialized; - -/** - * Builds a flat tree of conversations with depth levels for nested forks. - * Accepts a pre-filtered list so search filtering stays in the component. - */ -export function buildConversationTree(convs: DatabaseConversation[]): ConversationTreeItem[] { - const childrenByParent = new SvelteMap(); - const forkIds = new SvelteSet(); - - for (const conv of convs) { - if (conv.forkedFromConversationId) { - forkIds.add(conv.id); - - const siblings = childrenByParent.get(conv.forkedFromConversationId) || []; - - siblings.push(conv); - childrenByParent.set(conv.forkedFromConversationId, siblings); - } - } - - const result: ConversationTreeItem[] = []; - const visited = new SvelteSet(); - - function walk(conv: DatabaseConversation, depth: number) { - visited.add(conv.id); - result.push({ conversation: conv, depth }); - - const children = childrenByParent.get(conv.id); - if (children) { - children.sort((a, b) => b.lastModified - a.lastModified); - - for (const child of children) { - walk(child, depth + 1); - } - } - } - - const roots = convs.filter((c) => !forkIds.has(c.id)); - for (const root of roots) { - walk(root, 0); - } - - for (const conv of convs) { - if (!visited.has(conv.id)) { - walk(conv, 1); - } - } - - return result; -} diff --git a/tools/server/webui/src/lib/stores/draft-messages.svelte.ts b/tools/server/webui/src/lib/stores/draft-messages.svelte.ts deleted file mode 100644 index 7ee814d84..000000000 --- a/tools/server/webui/src/lib/stores/draft-messages.svelte.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NEW_CHAT_DRAFT_KEY } from '$lib/constants'; - -interface DraftMessage { - message: string; - files: ChatUploadedFile[]; -} - -class DraftMessagesStore { - private drafts = new Map(); - - getDraftMessage(chatId: string | undefined): DraftMessage { - const key = chatId ?? NEW_CHAT_DRAFT_KEY; - return this.drafts.get(key) ?? { message: '', files: [] }; - } - - saveDraftMessage(chatId: string | undefined, message: string, files: ChatUploadedFile[]): void { - const key = chatId ?? NEW_CHAT_DRAFT_KEY; - if (message || files.length > 0) { - this.drafts.set(key, { message, files: [...files] }); - } else { - this.drafts.delete(key); - } - } - - clearDraftMessage(chatId: string | undefined): void { - const key = chatId ?? NEW_CHAT_DRAFT_KEY; - this.drafts.delete(key); - } -} - -export const draftMessagesStore = new DraftMessagesStore(); diff --git a/tools/server/webui/src/lib/stores/mcp-resources.svelte.ts b/tools/server/webui/src/lib/stores/mcp-resources.svelte.ts deleted file mode 100644 index 18347fb75..000000000 --- a/tools/server/webui/src/lib/stores/mcp-resources.svelte.ts +++ /dev/null @@ -1,608 +0,0 @@ -/** - * mcpResourceStore - Reactive State Store for MCP Resources - * - * Manages MCP protocol resources: - * - Resource discovery and listing per server - * - Resource content caching - * - Resource subscriptions - * - Resource attachments for chat context - * - * @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18/server/resources - */ - -import { SvelteMap } from 'svelte/reactivity'; -import { AttachmentType } from '$lib/enums'; -import { - MCP_RESOURCE_ATTACHMENT_ID_PREFIX, - MCP_RESOURCE_CACHE_MAX_ENTRIES, - MCP_RESOURCE_CACHE_TTL_MS, - NEWLINE_SEPARATOR, - RESOURCE_UNKNOWN_TYPE, - BINARY_CONTENT_LABEL -} from '$lib/constants'; -import { normalizeResourceUri } from '$lib/utils'; -import type { - MCPResource, - MCPResourceTemplate, - MCPResourceContent, - MCPResourceInfo, - MCPResourceTemplateInfo, - MCPCachedResource, - MCPResourceAttachment, - MCPResourceSubscription, - MCPServerResources, - DatabaseMessageExtraMcpResource -} from '$lib/types'; - -function generateAttachmentId(): string { - return `${MCP_RESOURCE_ATTACHMENT_ID_PREFIX}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; -} - -class MCPResourceStore { - private _serverResources = $state>(new SvelteMap()); - private _cachedResources = $state>(new SvelteMap()); - private _subscriptions = $state>(new SvelteMap()); - private _attachments = $state([]); - private _isLoading = $state(false); - - get serverResources(): Map { - return this._serverResources; - } - - get cachedResources(): Map { - return this._cachedResources; - } - - get subscriptions(): Map { - return this._subscriptions; - } - - get attachments(): MCPResourceAttachment[] { - return this._attachments; - } - - get isLoading(): boolean { - return this._isLoading; - } - - get totalResourceCount(): number { - let count = 0; - for (const serverRes of this._serverResources.values()) { - count += serverRes.resources.length; - } - - return count; - } - - get totalTemplateCount(): number { - let count = 0; - for (const serverRes of this._serverResources.values()) { - count += serverRes.templates.length; - } - - return count; - } - - get attachmentCount(): number { - return this._attachments.length; - } - - get hasAttachments(): boolean { - return this._attachments.length > 0; - } - - /** - * - * - * Server Resources Management - * - * - */ - - /** - * Set resources for a server (called after listResources) - */ - setServerResources( - serverName: string, - resources: MCPResource[], - templates: MCPResourceTemplate[] - ): void { - this._serverResources.set(serverName, { - serverName, - resources, - templates, - lastFetched: new Date(), - loading: false, - error: undefined - }); - console.log( - `[MCPResources][${serverName}] Set ${resources.length} resources, ${templates.length} templates` - ); - } - - /** - * Set loading state for a server's resources - */ - setServerLoading(serverName: string, loading: boolean): void { - const existing = this._serverResources.get(serverName); - if (existing) { - this._serverResources.set(serverName, { ...existing, loading }); - } else { - this._serverResources.set(serverName, { - serverName, - resources: [], - templates: [], - loading, - error: undefined - }); - } - } - - /** - * Set error state for a server's resources - */ - setServerError(serverName: string, error: string): void { - const existing = this._serverResources.get(serverName); - - if (existing) { - this._serverResources.set(serverName, { ...existing, loading: false, error }); - } else { - this._serverResources.set(serverName, { - serverName, - resources: [], - templates: [], - loading: false, - error - }); - } - } - - /** - * Get resources for a specific server - */ - getServerResources(serverName: string): MCPServerResources | undefined { - return this._serverResources.get(serverName); - } - - /** - * Get all resources as MCPResourceInfo array (flattened with server names) - */ - getAllResourceInfos(): MCPResourceInfo[] { - const result: MCPResourceInfo[] = []; - - for (const [serverName, serverRes] of this._serverResources) { - for (const resource of serverRes.resources) { - result.push({ - uri: resource.uri, - name: resource.name, - title: resource.title, - description: resource.description, - mimeType: resource.mimeType, - serverName, - annotations: resource.annotations, - icons: resource.icons - }); - } - } - - return result; - } - - /** - * Get all templates as MCPResourceTemplateInfo array (flattened with server names) - */ - getAllTemplateInfos(): MCPResourceTemplateInfo[] { - const result: MCPResourceTemplateInfo[] = []; - - for (const [serverName, serverRes] of this._serverResources) { - for (const template of serverRes.templates) { - result.push({ - uriTemplate: template.uriTemplate, - name: template.name, - title: template.title, - description: template.description, - mimeType: template.mimeType, - serverName, - annotations: template.annotations, - icons: template.icons - }); - } - } - - return result; - } - - /** - * Clear resources for a server (e.g., when disconnected) - */ - clearServerResources(serverName: string): void { - this._serverResources.delete(serverName); - - // Also clear cached content for this server's resources - for (const [uri, cached] of this._cachedResources) { - if (cached.resource.serverName === serverName) { - this._cachedResources.delete(uri); - } - } - - // Clear subscriptions for this server - for (const [uri, sub] of this._subscriptions) { - if (sub.serverName === serverName) { - this._subscriptions.delete(uri); - } - } - - console.log(`[MCPResources][${serverName}] Cleared all resources`); - } - - /** - * - * - * Resource Content Caching - * - * - */ - - /** - * Cache resource content after reading - */ - cacheResourceContent(resource: MCPResourceInfo, content: MCPResourceContent[]): void { - // Enforce cache size limit - if (this._cachedResources.size >= MCP_RESOURCE_CACHE_MAX_ENTRIES) { - // Remove oldest entry - const oldestKey = this._cachedResources.keys().next().value; - - if (oldestKey) { - this._cachedResources.delete(oldestKey); - } - } - - this._cachedResources.set(resource.uri, { - resource, - content, - fetchedAt: new Date(), - subscribed: this._subscriptions.has(resource.uri) - }); - console.log(`[MCPResources] Cached content for: ${resource.uri}`); - } - - /** - * Get cached content for a resource - */ - getCachedContent(uri: string): MCPCachedResource | undefined { - const cached = this._cachedResources.get(uri); - if (!cached) return undefined; - - // Check if cache is still valid - const age = Date.now() - cached.fetchedAt.getTime(); - - if (age > MCP_RESOURCE_CACHE_TTL_MS && !cached.subscribed) { - // Cache expired and not subscribed, remove it - this._cachedResources.delete(uri); - - return undefined; - } - - return cached; - } - - /** - * Invalidate cached content for a resource (e.g., on update notification) - */ - invalidateCache(uri: string): void { - this._cachedResources.delete(uri); - console.log(`[MCPResources] Invalidated cache for: ${uri}`); - } - - /** - * Clear all cached content - */ - clearCache(): void { - this._cachedResources.clear(); - console.log(`[MCPResources] Cleared all cached content`); - } - - /** - * - * - * Subscriptions - * - * - */ - - /** - * Register a subscription for a resource - */ - addSubscription(uri: string, serverName: string): void { - this._subscriptions.set(uri, { - uri, - serverName, - subscribedAt: new Date() - }); - - // Update cached resource if exists - const cached = this._cachedResources.get(uri); - if (cached) { - this._cachedResources.set(uri, { ...cached, subscribed: true }); - } - - console.log(`[MCPResources] Added subscription: ${uri}`); - } - - /** - * Remove a subscription for a resource - */ - removeSubscription(uri: string): void { - this._subscriptions.delete(uri); - - // Update cached resource if exists - const cached = this._cachedResources.get(uri); - if (cached) { - this._cachedResources.set(uri, { ...cached, subscribed: false }); - } - - console.log(`[MCPResources] Removed subscription: ${uri}`); - } - - /** - * Check if a resource is subscribed - */ - isSubscribed(uri: string): boolean { - return this._subscriptions.has(uri); - } - - /** - * Handle resource update notification - */ - handleResourceUpdate(uri: string): void { - // Invalidate cache so next read gets fresh content - this.invalidateCache(uri); - - // Update subscription last update time - const sub = this._subscriptions.get(uri); - if (sub) { - this._subscriptions.set(uri, { ...sub, lastUpdate: new Date() }); - } - - console.log(`[MCPResources] Resource updated: ${uri}`); - } - - /** - * Handle resources list changed notification - */ - handleResourcesListChanged(serverName: string): void { - // Mark server resources as needing refresh - const existing = this._serverResources.get(serverName); - if (existing) { - this._serverResources.set(serverName, { - ...existing, - lastFetched: undefined // Mark as stale - }); - } - console.log(`[MCPResources][${serverName}] Resources list changed, needs refresh`); - } - - /** - * - * - * Attachments (for chat context) - * - * - */ - - /** - * Add a resource attachment to the current chat context - */ - addAttachment(resource: MCPResourceInfo): MCPResourceAttachment { - const attachment: MCPResourceAttachment = { - id: generateAttachmentId(), - resource, - loading: true - }; - - this._attachments = [...this._attachments, attachment]; - console.log(`[MCPResources] Added attachment: ${resource.uri}`); - - return attachment; - } - - /** - * Update attachment with fetched content - */ - updateAttachmentContent(attachmentId: string, content: MCPResourceContent[]): void { - this._attachments = this._attachments.map((att) => - att.id === attachmentId ? { ...att, content, loading: false, error: undefined } : att - ); - } - - /** - * Update attachment with error - */ - updateAttachmentError(attachmentId: string, error: string): void { - this._attachments = this._attachments.map((att) => - att.id === attachmentId ? { ...att, loading: false, error } : att - ); - } - - /** - * Remove an attachment - */ - removeAttachment(attachmentId: string): void { - this._attachments = this._attachments.filter((att) => att.id !== attachmentId); - console.log(`[MCPResources] Removed attachment: ${attachmentId}`); - } - - /** - * Clear all attachments - */ - clearAttachments(): void { - this._attachments = []; - console.log(`[MCPResources] Cleared all attachments`); - } - - /** - * Get attachment by ID - */ - getAttachment(attachmentId: string): MCPResourceAttachment | undefined { - return this._attachments.find((att) => att.id === attachmentId); - } - - /** - * Check if a resource is already attached - */ - isAttached(uri: string): boolean { - const normalizedUri = normalizeResourceUri(uri); - - return this._attachments.some( - (att) => att.resource.uri === uri || normalizeResourceUri(att.resource.uri) === normalizedUri - ); - } - - /** - * - * - * Utility Methods - * - * - */ - - /** - * Set global loading state - */ - setLoading(loading: boolean): void { - this._isLoading = loading; - } - - /** - * Find resource info by URI across all servers - */ - findResourceByUri(uri: string): MCPResourceInfo | undefined { - const normalizedUri = normalizeResourceUri(uri); - - for (const [serverName, serverRes] of this._serverResources) { - const resource = - serverRes.resources.find((r) => r.uri === uri) ?? - serverRes.resources.find((r) => normalizeResourceUri(r.uri) === normalizedUri); - - if (resource) { - return { - uri: resource.uri, - name: resource.name, - title: resource.title, - description: resource.description, - mimeType: resource.mimeType, - serverName, - annotations: resource.annotations, - icons: resource.icons - }; - } - } - - return undefined; - } - - /** - * Find server name for a resource URI - */ - findServerForUri(uri: string): string | undefined { - for (const [serverName, serverRes] of this._serverResources) { - if (serverRes.resources.some((r) => r.uri === uri)) { - return serverName; - } - } - - return undefined; - } - - /** - * Clear all state (e.g., on full reset) - */ - clear(): void { - this._serverResources.clear(); - this._cachedResources.clear(); - this._subscriptions.clear(); - this._attachments = []; - this._isLoading = false; - console.log(`[MCPResources] Cleared all state`); - } - - /** - * Get resource content as text for chat context - * Formats content for inclusion in LLM prompts - */ - formatAttachmentsForContext(): string { - if (this._attachments.length === 0) return ''; - - const parts: string[] = []; - - for (const attachment of this._attachments) { - if (attachment.error) continue; - if (!attachment.content || attachment.content.length === 0) continue; - - const resourceName = attachment.resource.title || attachment.resource.name; - const serverName = attachment.resource.serverName; - - for (const content of attachment.content) { - if ('text' in content && content.text) { - parts.push(`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n${content.text}`); - } else if ('blob' in content && content.blob) { - // For binary content, just note it exists - parts.push( - `\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]` - ); - } - } - } - - return parts.join(''); - } - - /** - * Convert current resource attachments to DatabaseMessageExtra[] for persisting with a message. - * Each attachment becomes a DatabaseMessageExtraMcpResource stored on the user message. - */ - toMessageExtras(): DatabaseMessageExtraMcpResource[] { - const extras: DatabaseMessageExtraMcpResource[] = []; - - for (const attachment of this._attachments) { - if (attachment.error) continue; - if (!attachment.content || attachment.content.length === 0) continue; - - const resourceName = attachment.resource.title || attachment.resource.name; - const contentParts: string[] = []; - - for (const content of attachment.content) { - if ('text' in content && content.text) { - contentParts.push(content.text); - } else if ('blob' in content && content.blob) { - contentParts.push( - `[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]` - ); - } - } - - if (contentParts.length > 0) { - extras.push({ - type: AttachmentType.MCP_RESOURCE, - name: resourceName, - uri: attachment.resource.uri, - serverName: attachment.resource.serverName, - content: contentParts.join(NEWLINE_SEPARATOR), - mimeType: attachment.resource.mimeType - }); - } - } - - return extras; - } -} - -export const mcpResourceStore = new MCPResourceStore(); - -// Export convenience functions -export const mcpResources = () => mcpResourceStore.serverResources; -export const mcpResourceAttachments = () => mcpResourceStore.attachments; -export const mcpResourceAttachmentCount = () => mcpResourceStore.attachmentCount; -export const mcpHasResourceAttachments = () => mcpResourceStore.hasAttachments; -export const mcpTotalResourceCount = () => mcpResourceStore.totalResourceCount; -export const mcpResourcesLoading = () => mcpResourceStore.isLoading; diff --git a/tools/server/webui/src/lib/stores/mcp.svelte.ts b/tools/server/webui/src/lib/stores/mcp.svelte.ts deleted file mode 100644 index 6fb4e0766..000000000 --- a/tools/server/webui/src/lib/stores/mcp.svelte.ts +++ /dev/null @@ -1,1972 +0,0 @@ -/** - * mcpStore - Reactive State Store for MCP Operations - * - * Implements the "Host" role in MCP architecture, coordinating multiple server - * connections and providing a unified interface for tool operations. - * - * **Architecture & Relationships:** - * - **MCPService**: Stateless protocol layer (transport, connect, callTool) - * - **mcpStore** (this): Reactive state + business logic - * - * **Key Responsibilities:** - * - Lifecycle management (initialize, shutdown) - * - Multi-server coordination - * - Tool name conflict detection and resolution - * - OpenAI-compatible tool definition generation - * - Automatic tool-to-server routing - * - Health checks - * - * @see MCPService in services/mcp.service.ts for protocol operations - */ - -import { browser } from '$app/environment'; -import { base } from '$app/paths'; -import { SETTINGS_KEYS } from '$lib/constants'; -import { MCPService } from '$lib/services/mcp.service'; -import { config, settingsStore } from '$lib/stores/settings.svelte'; -import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte'; -import { mode } from 'mode-watcher'; -import { - parseMcpServerSettings, - detectMcpTransportFromUrl, - uuid, - extractRootDomain -} from '$lib/utils'; -import { - MCPConnectionPhase, - MCPLogLevel, - HealthCheckStatus, - MCPRefType, - ColorMode, - UrlProtocol, - JsonSchemaType, - ToolCallType -} from '$lib/enums'; -import { - CORS_PROXY_ENDPOINT, - DEFAULT_CACHE_TTL_MS, - DEFAULT_MCP_CONFIG, - EXPECTED_THEMED_ICON_PAIR_COUNT, - MCP_ALLOWED_ICON_MIME_TYPES, - MCP_SERVER_ID_PREFIX, - MCP_RECONNECT_INITIAL_DELAY, - MCP_RECONNECT_BACKOFF_MULTIPLIER, - MCP_RECONNECT_MAX_DELAY, - MCP_RECONNECT_ATTEMPT_TIMEOUT_MS -} from '$lib/constants'; -import type { - MCPToolCall, - OpenAIToolDefinition, - ServerStatus, - ToolExecutionResult, - MCPClientConfig, - MCPConnection, - HealthCheckParams, - ServerCapabilities, - ClientCapabilities, - MCPCapabilitiesInfo, - MCPConnectionLog, - MCPPromptInfo, - GetPromptResult, - Tool, - HealthCheckState, - MCPServerSettingsEntry, - MCPServerConfig, - MCPResourceIcon, - MCPResourceAttachment, - MCPResourceContent -} from '$lib/types'; -import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js'; -import type { DatabaseMessageExtraMcpResource, McpServerOverride } from '$lib/types/database'; -import type { SettingsConfigType } from '$lib/types/settings'; - -class MCPStore { - private _isInitializing = $state(false); - private _error = $state(null); - private _toolCount = $state(0); - private _connectedServers = $state([]); - private _healthChecks = $state>({}); - private _proxyAvailable = $state(false); - - private connections = new Map(); - private toolsIndex = new Map(); - private serverConfigs = new Map(); // Store configs for reconnection - private reconnectingServers = new Set(); // Guard against concurrent reconnections - private configSignature: string | null = null; - private initPromise: Promise | null = null; - private activeFlowCount = 0; - - constructor() { - if (browser) { - this.probeProxy(); - } - } - - /** - * Probes the CORS proxy endpoint to determine availability. - * The endpoint is only registered when llama-server runs with --webui-mcp-proxy. - */ - async probeProxy(): Promise { - try { - const response = await fetch(`${base}${CORS_PROXY_ENDPOINT}`, { method: 'HEAD' }); - this._proxyAvailable = response.status !== 404; - } catch { - this._proxyAvailable = false; - } - } - - get isProxyAvailable(): boolean { - return this._proxyAvailable; - } - - /** - * Generates a unique server ID from an optional ID string or index. - */ - #generateServerId(id: unknown, index: number): string { - if (typeof id === 'string' && id.trim()) { - return id.trim(); - } - - return `${MCP_SERVER_ID_PREFIX}-${index + 1}`; - } - - /** - * Parses raw server settings from config into MCPServerSettingsEntry array. - */ - #parseServerSettings(rawServers: unknown): MCPServerSettingsEntry[] { - if (!rawServers) { - return []; - } - - let parsed: unknown; - if (typeof rawServers === 'string') { - const trimmed = rawServers.trim(); - if (!trimmed) { - return []; - } - - try { - parsed = JSON.parse(trimmed); - } catch (error) { - console.warn('[MCP] Failed to parse mcpServers JSON:', error); - - return []; - } - } else { - parsed = rawServers; - } - if (!Array.isArray(parsed)) { - return []; - } - - return parsed.map((entry, index) => { - const url = typeof entry?.url === 'string' ? entry.url.trim() : ''; - const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined; - - return { - id: this.#generateServerId((entry as { id?: unknown })?.id, index), - enabled: Boolean((entry as { enabled?: unknown })?.enabled), - url, - name: (entry as { name?: string })?.name, - requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds, - headers: headers || undefined, - useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy) - } satisfies MCPServerSettingsEntry; - }); - } - - /** - * Builds server configuration from a settings entry. - */ - #buildServerConfig( - entry: MCPServerSettingsEntry, - connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs - ): MCPServerConfig | undefined { - if (!entry?.url) { - return undefined; - } - - let headers: Record | undefined; - if (entry.headers) { - try { - const parsed = JSON.parse(entry.headers); - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) - headers = parsed as Record; - } catch { - console.warn('[MCP] Failed to parse custom headers JSON:', entry.headers); - } - } - - return { - url: entry.url, - transport: detectMcpTransportFromUrl(entry.url), - handshakeTimeoutMs: connectionTimeoutMs, - requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000), - headers, - useProxy: entry.useProxy - }; - } - - /** - * Checks if a server is enabled for a given chat. - * Only per-chat overrides (persisted in localStorage for new chats, - * or in IndexedDB for existing conversations) control enabled state. - */ - #checkServerEnabled( - server: MCPServerSettingsEntry, - perChatOverrides?: McpServerOverride[] - ): boolean { - const override = perChatOverrides?.find((o) => o.serverId === server.id); - return override?.enabled ?? false; - } - - /** - * Builds MCP client configuration from settings. - */ - #buildMcpClientConfig( - cfg: SettingsConfigType, - perChatOverrides?: McpServerOverride[] - ): MCPClientConfig | undefined { - const rawServers = this.#parseServerSettings(cfg.mcpServers); - if (!rawServers.length) { - return undefined; - } - - const servers: Record = {}; - - for (const [index, entry] of rawServers.entries()) { - if (!this.#checkServerEnabled(entry, perChatOverrides)) continue; - const normalized = this.#buildServerConfig(entry); - if (normalized) servers[this.#generateServerId(entry.id, index)] = normalized; - } - - if (Object.keys(servers).length === 0) { - return undefined; - } - - return { - protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion, - capabilities: DEFAULT_MCP_CONFIG.capabilities, - clientInfo: DEFAULT_MCP_CONFIG.clientInfo, - requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000), - servers - }; - } - - /** - * Builds capabilities info from server and client capabilities. - */ - #buildCapabilitiesInfo( - serverCaps?: ServerCapabilities, - clientCaps?: ClientCapabilities - ): MCPCapabilitiesInfo { - return { - server: { - tools: serverCaps?.tools ? { listChanged: serverCaps.tools.listChanged } : undefined, - prompts: serverCaps?.prompts ? { listChanged: serverCaps.prompts.listChanged } : undefined, - resources: serverCaps?.resources - ? { - subscribe: serverCaps.resources.subscribe, - listChanged: serverCaps.resources.listChanged - } - : undefined, - logging: !!serverCaps?.logging, - completions: !!serverCaps?.completions, - tasks: !!serverCaps?.tasks - }, - client: { - roots: clientCaps?.roots ? { listChanged: clientCaps.roots.listChanged } : undefined, - sampling: !!clientCaps?.sampling, - elicitation: clientCaps?.elicitation - ? { form: !!clientCaps.elicitation.form, url: !!clientCaps.elicitation.url } - : undefined, - tasks: !!clientCaps?.tasks - } - }; - } - - get isInitializing(): boolean { - return this._isInitializing; - } - - get isInitialized(): boolean { - return this.connections.size > 0; - } - - get error(): string | null { - return this._error; - } - - get toolCount(): number { - return this._toolCount; - } - - get connectedServerCount(): number { - return this._connectedServers.length; - } - - get connectedServerNames(): string[] { - return this._connectedServers; - } - - get isEnabled(): boolean { - const mcpConfig = this.#buildMcpClientConfig(config()); - return ( - mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0 - ); - } - - get availableTools(): string[] { - return Array.from(this.toolsIndex.keys()); - } - - private updateState(state: { - isInitializing?: boolean; - error?: string | null; - toolCount?: number; - connectedServers?: string[]; - }): void { - if (state.isInitializing !== undefined) { - this._isInitializing = state.isInitializing; - } - - if (state.error !== undefined) { - this._error = state.error; - } - - if (state.toolCount !== undefined) { - this._toolCount = state.toolCount; - } - - if (state.connectedServers !== undefined) { - this._connectedServers = state.connectedServers; - } - } - - updateHealthCheck(serverId: string, state: HealthCheckState): void { - this._healthChecks = { ...this._healthChecks, [serverId]: state }; - } - - getHealthCheckState(serverId: string): HealthCheckState { - return this._healthChecks[serverId] ?? { status: HealthCheckStatus.IDLE }; - } - - hasHealthCheck(serverId: string): boolean { - return ( - serverId in this._healthChecks && - this._healthChecks[serverId].status !== HealthCheckStatus.IDLE - ); - } - - clearHealthCheck(serverId: string): void { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { [serverId]: _removed, ...rest } = this._healthChecks; - this._healthChecks = rest; - } - - clearAllHealthChecks(): void { - this._healthChecks = {}; - } - - clearError(): void { - this._error = null; - } - - getServers(): MCPServerSettingsEntry[] { - return parseMcpServerSettings(config().mcpServers); - } - - /** - * Get all active MCP connections. - * @returns Map of server names to connections - */ - getConnections(): Map { - return this.connections; - } - - getServerLabel(server: MCPServerSettingsEntry): string { - const healthState = this.getHealthCheckState(server.id); - - if (healthState?.status === HealthCheckStatus.SUCCESS) - return ( - healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url - ); - return server.url; - } - - getServerById(serverId: string): MCPServerSettingsEntry | undefined { - return this.getServers().find((s) => s.id === serverId); - } - - /** - * Get display name for an MCP server by its ID. - * Falls back to the server ID if server is not found. - */ - getServerDisplayName(serverId: string): string { - const server = this.getServerById(serverId); - return server ? this.getServerLabel(server) : serverId; - } - - /** - * Validates that an icon URI uses a safe scheme (https: or data:). - */ - #isValidIconUri(src: string): boolean { - try { - if (src.startsWith(UrlProtocol.DATA)) return true; - - const url = new URL(src); - - return url.protocol === UrlProtocol.HTTPS; - } catch { - return false; - } - } - - /** - * Selects the best icon URL from an MCP icons array. - * Follows security guidelines from the MCP specification: - * - Only allows https: and data: URIs - * - Filters to supported MIME types - * - * Selection priority: - * 1. Icon matching the current color scheme (dark/light) - * 2. Universal icon (no theme specified); if exactly 2, assumes [0]=light, [1]=dark - * 3. First valid icon as last resort - */ - #getMcpIconUrl(icons: MCPResourceIcon[] | undefined, isDark = false): string | null { - if (!icons?.length) return null; - - const validIcons = icons.filter((icon) => { - if (!icon.src || !this.#isValidIconUri(icon.src)) return false; - if (icon.mimeType && !MCP_ALLOWED_ICON_MIME_TYPES.has(icon.mimeType)) return false; - return true; - }); - - if (validIcons.length === 0) return null; - - const preferredTheme = isDark ? ColorMode.DARK : ColorMode.LIGHT; - - // 1. Prefer icon explicitly matching the current color scheme - const themedIcon = validIcons.find((icon) => icon.theme === preferredTheme); - if (themedIcon) return themedIcon.src; - - // 2. Handle universal icons (no theme specified) - const universalIcons = validIcons.filter((icon) => !icon.theme); - - if (universalIcons.length === EXPECTED_THEMED_ICON_PAIR_COUNT) { - // Heuristic: two theme-less icons → assume [0] = light, [1] = dark - return universalIcons[isDark ? 1 : 0].src; - } - - if (universalIcons.length > 0) { - return universalIcons[0].src; - } - - // 3. Last resort: use opposite-theme icon - return validIcons[0].src; - } - - /** - * Get icon URL for an MCP server by its ID. - * Returns the best icon from the MCP server's `icons` array - * (see MCP spec: spec.modelcontextprotocol.io). - * Returns null if no icon is available. - */ - getServerFavicon(serverId: string): string | null { - const server = this.getServerById(serverId); - if (!server) { - return null; - } - - const isDark = mode.current === ColorMode.DARK; - const healthState = this.getHealthCheckState(serverId); - if (healthState.status === HealthCheckStatus.SUCCESS && healthState.serverInfo?.icons) { - const mcpIconUrl = this.#getMcpIconUrl(healthState.serverInfo.icons, isDark); - - if (mcpIconUrl) { - return mcpIconUrl; - } - } - - // Fallback: try favicon from root domain - const fallbackUrl = this.#getServerFaviconFallback(server.url); - if (fallbackUrl) { - return fallbackUrl; - } - - return null; - } - - /** - * Construct a fallback favicon URL from the MCP server URL. - * e.g. https://mcp.exa.ai/mcp -> https://exa.ai/favicon.ico - */ - #getServerFaviconFallback(serverUrl: string): string | null { - try { - const url = new URL(serverUrl); - const rootDomain = extractRootDomain(url); - if (!rootDomain) return null; - - const origin = `${url.protocol}//${rootDomain}`; - const candidates = ['favicon.ico', 'favicon.svg', 'favicon.png']; - - for (const path of candidates) { - const faviconUrl = `${origin}/${path}`; - if (this.#isValidIconUri(faviconUrl)) { - return faviconUrl; - } - } - } catch { - // Invalid URL, return null - } - - return null; - } - - isAnyServerLoading(): boolean { - return this.getServers().some((s) => { - const state = this.getHealthCheckState(s.id); - - return ( - state.status === HealthCheckStatus.IDLE || state.status === HealthCheckStatus.CONNECTING - ); - }); - } - - getServersSorted(): MCPServerSettingsEntry[] { - const servers = this.getServers(); - if (this.isAnyServerLoading()) { - return servers; - } - - return [...servers].sort((a, b) => - this.getServerLabel(a).localeCompare(this.getServerLabel(b)) - ); - } - - addServer( - serverData: Omit & { id?: string } - ): void { - const servers = this.getServers(); - const newServer: MCPServerSettingsEntry = { - id: serverData.id || (uuid() ?? `server-${Date.now()}`), - enabled: serverData.enabled, - url: serverData.url.trim(), - name: serverData.name, - headers: serverData.headers?.trim() || undefined, - requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds, - useProxy: serverData.useProxy - }; - settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, JSON.stringify([...servers, newServer])); - } - - updateServer(id: string, updates: Partial): void { - const servers = this.getServers(); - settingsStore.updateConfig( - SETTINGS_KEYS.MCP_SERVERS, - JSON.stringify( - servers.map((server) => (server.id === id ? { ...server, ...updates } : server)) - ) - ); - } - - removeServer(id: string): void { - const servers = this.getServers(); - settingsStore.updateConfig( - SETTINGS_KEYS.MCP_SERVERS, - JSON.stringify(servers.filter((s) => s.id !== id)) - ); - this.clearHealthCheck(id); - } - - hasAvailableServers(): boolean { - return parseMcpServerSettings(config().mcpServers).some((s) => s.enabled && s.url.trim()); - } - hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean { - return Boolean(this.#buildMcpClientConfig(config(), perChatOverrides)); - } - - getEnabledServersForConversation( - perChatOverrides?: McpServerOverride[] - ): MCPServerSettingsEntry[] { - return this.getServers().filter((server) => { - return this.#checkServerEnabled(server, perChatOverrides); - }); - } - - async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise { - if (!browser) { - return false; - } - - const mcpConfig = this.#buildMcpClientConfig(config(), perChatOverrides); - const signature = mcpConfig ? JSON.stringify(mcpConfig) : null; - if (!signature) { - await this.shutdown(); - - return false; - } - if (this.isInitialized && this.configSignature === signature) { - return true; - } - - if (this.initPromise && this.configSignature === signature) { - return this.initPromise; - } - - if (this.connections.size > 0 || this.initPromise) await this.shutdown(); - return this.initialize(signature, mcpConfig!); - } - - private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise { - this.updateState({ isInitializing: true, error: null }); - this.configSignature = signature; - - const serverEntries = Object.entries(mcpConfig.servers); - - if (serverEntries.length === 0) { - this.updateState({ isInitializing: false, toolCount: 0, connectedServers: [] }); - - return false; - } - this.initPromise = this.doInitialize(signature, mcpConfig, serverEntries); - - return this.initPromise; - } - - private async doInitialize( - signature: string, - mcpConfig: MCPClientConfig, - serverEntries: [string, MCPClientConfig['servers'][string]][] - ): Promise { - const clientInfo = mcpConfig.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo; - const capabilities = mcpConfig.capabilities ?? DEFAULT_MCP_CONFIG.capabilities; - const results = await Promise.allSettled( - serverEntries.map(async ([name, serverConfig]) => { - // Store config for reconnection - this.serverConfigs.set(name, serverConfig); - - const listChangedHandlers = this.createListChangedHandlers(name); - const connection = await MCPService.connect( - name, - serverConfig, - clientInfo, - capabilities, - (phase) => { - // Handle WebSocket disconnection - if (phase === MCPConnectionPhase.DISCONNECTED) { - console.log(`[MCPStore][${name}] Connection lost, starting auto-reconnect`); - this.autoReconnect(name); - } - }, - listChangedHandlers - ); - - return { name, connection }; - }) - ); - if (this.configSignature !== signature) { - for (const result of results) { - if (result.status === 'fulfilled') - await MCPService.disconnect(result.value.connection).catch(console.warn); - } - - return false; - } - for (const result of results) { - if (result.status === 'fulfilled') { - const { name, connection } = result.value; - - this.connections.set(name, connection); - - for (const tool of connection.tools) { - if (this.toolsIndex.has(tool.name)) - console.warn( - `[MCPStore] Tool name conflict: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${name}". Using tool from "${name}".` - ); - this.toolsIndex.set(tool.name, name); - } - } else { - console.error(`[MCPStore] Failed to connect:`, result.reason); - } - } - - const successCount = this.connections.size; - if (successCount === 0 && serverEntries.length > 0) { - this.updateState({ - isInitializing: false, - error: 'All MCP server connections failed', - toolCount: 0, - connectedServers: [] - }); - this.initPromise = null; - - return false; - } - - this.updateState({ - isInitializing: false, - error: null, - toolCount: this.toolsIndex.size, - connectedServers: Array.from(this.connections.keys()) - }); - this.initPromise = null; - - return true; - } - - private createListChangedHandlers(serverName: string): ListChangedHandlers { - return { - tools: { - onChanged: (error: Error | null, tools: Tool[] | null) => { - if (error) { - console.warn(`[MCPStore][${serverName}] Tools list changed error:`, error); - return; - } - this.handleToolsListChanged(serverName, tools ?? []); - } - }, - prompts: { - onChanged: (error: Error | null) => { - if (error) { - console.warn(`[MCPStore][${serverName}] Prompts list changed error:`, error); - return; - } - } - } - }; - } - - private handleToolsListChanged(serverName: string, tools: Tool[]): void { - const connection = this.connections.get(serverName); - if (!connection) { - return; - } - - for (const [toolName, ownerServer] of this.toolsIndex.entries()) { - if (ownerServer === serverName) this.toolsIndex.delete(toolName); - } - - connection.tools = tools; - - for (const tool of tools) { - if (this.toolsIndex.has(tool.name)) - console.warn( - `[MCPStore] Tool name conflict after list change: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${serverName}". Using tool from "${serverName}".` - ); - this.toolsIndex.set(tool.name, serverName); - } - this.updateState({ toolCount: this.toolsIndex.size }); - } - - acquireConnection(): void { - this.activeFlowCount++; - } - - /** - * Release a connection reference. - * By default, keeps connections alive for reuse (shutdownIfUnused=false). - * MCP spec encourages long-lived sessions to avoid reconnection overhead. - */ - async releaseConnection(shutdownIfUnused = false): Promise { - this.activeFlowCount = Math.max(0, this.activeFlowCount - 1); - if (shutdownIfUnused && this.activeFlowCount === 0) { - await this.shutdown(); - } - } - - getActiveFlowCount(): number { - return this.activeFlowCount; - } - - async shutdown(): Promise { - if (this.initPromise) { - await this.initPromise.catch(() => {}); - this.initPromise = null; - } - - if (this.connections.size === 0) { - return; - } - - await Promise.all( - Array.from(this.connections.values()).map((conn) => - MCPService.disconnect(conn).catch((error) => - console.warn(`[MCPStore] Error disconnecting ${conn.serverName}:`, error) - ) - ) - ); - - this.connections.clear(); - this.toolsIndex.clear(); - this.serverConfigs.clear(); - this.configSignature = null; - this.updateState({ isInitializing: false, error: null, toolCount: 0, connectedServers: [] }); - } - - /** - * Immediately reconnect to a server by creating a fresh transport and session. - * Used when a session-expired error (HTTP 404) is detected during tool execution. - * Per MCP spec 2025-11-25: client MUST discard session ID and re-initialize. - * - * Unlike autoReconnect (which uses exponential backoff for connectivity issues), - * this performs a single immediate reconnection attempt since the server is known - * to be reachable (it responded with 404). - */ - private async reconnectServer(serverName: string): Promise { - const serverConfig = this.serverConfigs.get(serverName); - if (!serverConfig) { - throw new Error(`[MCPStore] No config found for ${serverName}, cannot reconnect`); - } - - // Disconnect stale connection (clears old transport + session ID) - const oldConnection = this.connections.get(serverName); - if (oldConnection) { - await MCPService.disconnect(oldConnection).catch(console.warn); - this.connections.delete(serverName); - } - - console.log(`[MCPStore][${serverName}] Session expired, reconnecting with fresh session...`); - - const listChangedHandlers = this.createListChangedHandlers(serverName); - const connection = await MCPService.connect( - serverName, - serverConfig, - DEFAULT_MCP_CONFIG.clientInfo, - DEFAULT_MCP_CONFIG.capabilities, - (phase) => { - if (phase === MCPConnectionPhase.DISCONNECTED) { - console.log(`[MCPStore][${serverName}] Connection lost, starting auto-reconnect`); - this.autoReconnect(serverName); - } - }, - listChangedHandlers - ); - - // Replace connection and rebuild tool index for this server - this.connections.set(serverName, connection); - for (const tool of connection.tools) { - this.toolsIndex.set(tool.name, serverName); - } - - console.log(`[MCPStore][${serverName}] Session recovered successfully`); - } - - /** - * Auto-reconnect to a server with exponential backoff. - * Continues indefinitely until successful. - * - * Race-condition safety: when the phase callback fires a DISCONNECTED event - * while we are still inside this function (e.g., the server drops right after - * a successful connect()), a naive inner `autoReconnect()` call would be - * swallowed by the `reconnectingServers` guard, leaving the server - * permanently disconnected once the outer call exits. We solve this by - * deferring the new reconnection via the `needsReconnect` flag: the flag is - * set inside the phase callback and honoured in the `finally` block after - * the guard entry has been removed. - */ - private async autoReconnect(serverName: string): Promise { - // Guard against concurrent reconnections - if (this.reconnectingServers.has(serverName)) { - console.log(`[MCPStore][${serverName}] Reconnection already in progress, skipping`); - - return; - } - - const serverConfig = this.serverConfigs.get(serverName); - if (!serverConfig) { - console.error(`[MCPStore] No config found for ${serverName}, cannot reconnect`); - - return; - } - - this.reconnectingServers.add(serverName); - let backoff = MCP_RECONNECT_INITIAL_DELAY; - // Flag set by the phase callback when a DISCONNECTED event fires while - // reconnectingServers still holds this server (see JSDoc above). - let needsReconnect = false; - - try { - while (true) { - await new Promise((resolve) => setTimeout(resolve, backoff)); - - console.log(`[MCPStore][${serverName}] Auto-reconnecting...`); - - try { - // Per-attempt timeout: reject if the server doesn't respond in time, - // then fall through to backoff logic as with any other failure. - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => - reject( - new Error( - `Reconnect attempt timed out after ${MCP_RECONNECT_ATTEMPT_TIMEOUT_MS}ms` - ) - ), - MCP_RECONNECT_ATTEMPT_TIMEOUT_MS - ) - ); - - needsReconnect = false; - const listChangedHandlers = this.createListChangedHandlers(serverName); - const connectPromise = MCPService.connect( - serverName, - serverConfig, - DEFAULT_MCP_CONFIG.clientInfo, - DEFAULT_MCP_CONFIG.capabilities, - (phase) => { - if (phase === MCPConnectionPhase.DISCONNECTED) { - if (this.reconnectingServers.has(serverName)) { - // Reconnect loop is active; defer to after it exits. - needsReconnect = true; - } else { - console.log( - `[MCPStore][${serverName}] Connection lost, restarting auto-reconnect` - ); - this.autoReconnect(serverName); - } - } - }, - listChangedHandlers - ); - - const connection = await Promise.race([connectPromise, timeoutPromise]); - - // Replace old connection with new one - this.connections.set(serverName, connection); - - // Rebuild tool index for this server - for (const tool of connection.tools) { - this.toolsIndex.set(tool.name, serverName); - } - - console.log(`[MCPStore][${serverName}] Reconnected successfully`); - break; - } catch (error) { - console.warn(`[MCPStore][${serverName}] Reconnection failed:`, error); - backoff = Math.min(backoff * MCP_RECONNECT_BACKOFF_MULTIPLIER, MCP_RECONNECT_MAX_DELAY); - } - } - } finally { - this.reconnectingServers.delete(serverName); - // If the phase callback signalled a disconnect while this function held - // the guard, kick off a fresh reconnect now that the guard is released. - if (needsReconnect) { - console.log( - `[MCPStore][${serverName}] Deferred disconnect detected, restarting auto-reconnect` - ); - this.autoReconnect(serverName); - } - } - } - - getToolDefinitionsForLLM(): OpenAIToolDefinition[] { - const tools: OpenAIToolDefinition[] = []; - - for (const connection of this.connections.values()) { - for (const tool of connection.tools) { - const rawSchema = (tool.inputSchema as Record) ?? { - type: JsonSchemaType.OBJECT, - properties: {}, - required: [] - }; - - tools.push({ - type: ToolCallType.FUNCTION as const, - function: { - name: tool.name, - description: tool.description, - parameters: this.normalizeSchemaProperties(rawSchema) - } - }); - } - } - - return tools; - } - - private normalizeSchemaProperties(schema: Record): Record { - if (!schema || typeof schema !== 'object') { - return schema; - } - - const normalized = { ...schema }; - if (normalized.properties && typeof normalized.properties === 'object') { - const props = normalized.properties as Record>; - const normalizedProps: Record> = {}; - for (const [key, prop] of Object.entries(props)) { - if (!prop || typeof prop !== 'object') { - normalizedProps[key] = prop; - continue; - } - const normalizedProp = { ...prop }; - if (!normalizedProp.type && normalizedProp.default !== undefined) { - const defaultVal = normalizedProp.default; - if (typeof defaultVal === 'string') normalizedProp.type = 'string'; - else if (typeof defaultVal === 'number') - normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number'; - else if (typeof defaultVal === 'boolean') normalizedProp.type = 'boolean'; - else if (Array.isArray(defaultVal)) normalizedProp.type = 'array'; - else if (typeof defaultVal === 'object' && defaultVal !== null) - normalizedProp.type = 'object'; - } - if (normalizedProp.properties) - Object.assign( - normalizedProp, - this.normalizeSchemaProperties(normalizedProp as Record) - ); - if (normalizedProp.items && typeof normalizedProp.items === 'object') - normalizedProp.items = this.normalizeSchemaProperties( - normalizedProp.items as Record - ); - normalizedProps[key] = normalizedProp; - } - normalized.properties = normalizedProps; - } - - return normalized; - } - - getToolNames(): string[] { - return Array.from(this.toolsIndex.keys()); - } - - hasTool(toolName: string): boolean { - return this.toolsIndex.has(toolName); - } - - getToolServer(toolName: string): string | undefined { - return this.toolsIndex.get(toolName); - } - - hasPromptsSupport(): boolean { - for (const connection of this.connections.values()) { - if (connection.serverCapabilities?.prompts) { - return true; - } - } - - return false; - } - - /** - * Check if any enabled server with successful health check supports prompts. - * Uses health check state since servers may not have active connections until - * the user actually sends a message or uses prompts. - * @param perChatOverrides - Per-chat server overrides to filter by enabled servers. - * If provided (even empty array), only checks enabled servers. - * If undefined, checks all servers with successful health checks. - */ - hasPromptsCapability(perChatOverrides?: McpServerOverride[]): boolean { - // If perChatOverrides is provided (even empty array), filter by enabled servers - if (perChatOverrides !== undefined) { - const enabledServerIds = new Set( - perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId) - ); - - // No enabled servers = no capability - if (enabledServerIds.size === 0) { - return false; - } - - // Check health check states for enabled servers with prompts capability - for (const [serverId, state] of Object.entries(this._healthChecks)) { - if (!enabledServerIds.has(serverId)) continue; - if ( - state.status === HealthCheckStatus.SUCCESS && - state.capabilities?.server?.prompts !== undefined - ) { - return true; - } - } - - // Also check active connections as fallback - for (const [serverName, connection] of this.connections) { - if (!enabledServerIds.has(serverName)) continue; - if (connection.serverCapabilities?.prompts) { - return true; - } - } - - return false; - } - - // No overrides provided - check all servers (global mode) - for (const state of Object.values(this._healthChecks)) { - if ( - state.status === HealthCheckStatus.SUCCESS && - state.capabilities?.server?.prompts !== undefined - ) { - return true; - } - } - - for (const connection of this.connections.values()) { - if (connection.serverCapabilities?.prompts) { - return true; - } - } - - return false; - } - - async getAllPrompts(): Promise { - const results: MCPPromptInfo[] = []; - - for (const [serverName, connection] of this.connections) { - if (!connection.serverCapabilities?.prompts) continue; - - const prompts = await MCPService.listPrompts(connection); - - for (const prompt of prompts) { - results.push({ - name: prompt.name, - description: prompt.description, - title: prompt.title, - serverName, - arguments: prompt.arguments?.map((arg) => ({ - name: arg.name, - description: arg.description, - required: arg.required - })) - }); - } - } - - return results; - } - - async getPrompt( - serverName: string, - promptName: string, - args?: Record - ): Promise { - const connection = this.connections.get(serverName); - if (!connection) throw new Error(`Server "${serverName}" not found for prompt "${promptName}"`); - - return MCPService.getPrompt(connection, promptName, args); - } - - async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise { - const toolName = toolCall.function.name; - - const serverName = this.toolsIndex.get(toolName); - if (!serverName) throw new Error(`Unknown tool: ${toolName}`); - - const connection = this.connections.get(serverName); - if (!connection) throw new Error(`Server "${serverName}" is not connected`); - - const args = this.parseToolArguments(toolCall.function.arguments); - - try { - return await MCPService.callTool(connection, { name: toolName, arguments: args }, signal); - } catch (error) { - // Session expired (server restarted) - reconnect and retry once - if (MCPService.isSessionExpiredError(error)) { - await this.reconnectServer(serverName); - - const newConnection = this.connections.get(serverName); - if (!newConnection) throw new Error(`Failed to reconnect to "${serverName}"`); - - return MCPService.callTool(newConnection, { name: toolName, arguments: args }, signal); - } - - throw error; - } - } - - async executeToolByName( - toolName: string, - args: Record, - signal?: AbortSignal - ): Promise { - const serverName = this.toolsIndex.get(toolName); - if (!serverName) throw new Error(`Unknown tool: ${toolName}`); - const connection = this.connections.get(serverName); - if (!connection) throw new Error(`Server "${serverName}" is not connected`); - - try { - return await MCPService.callTool(connection, { name: toolName, arguments: args }, signal); - } catch (error) { - if (MCPService.isSessionExpiredError(error)) { - await this.reconnectServer(serverName); - - const newConnection = this.connections.get(serverName); - if (!newConnection) throw new Error(`Failed to reconnect to "${serverName}"`); - - return MCPService.callTool(newConnection, { name: toolName, arguments: args }, signal); - } - - throw error; - } - } - - private parseToolArguments(args: string | Record): Record { - if (typeof args === 'string') { - const trimmed = args.trim(); - if (trimmed === '') { - return {}; - } - - try { - const parsed = JSON.parse(trimmed); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) - throw new Error( - `Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}` - ); - - return parsed as Record; - } catch (error) { - throw new Error(`Failed to parse tool arguments as JSON: ${(error as Error).message}`); - } - } - - if (typeof args === 'object' && args !== null && !Array.isArray(args)) { - return args; - } - - throw new Error(`Invalid tool arguments type: ${typeof args}`); - } - - async getPromptCompletions( - serverName: string, - promptName: string, - argumentName: string, - argumentValue: string - ): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> { - const connection = this.connections.get(serverName); - if (!connection) { - console.warn(`[MCPStore] Server "${serverName}" is not connected`); - return null; - } - if (!connection.serverCapabilities?.completions) { - return null; - } - - return MCPService.complete( - connection, - { type: MCPRefType.PROMPT, name: promptName }, - { name: argumentName, value: argumentValue } - ); - } - - /** - * Get completions for a resource template argument. - * Uses the MCP Completion API with ref/resource. - */ - async getResourceCompletions( - serverName: string, - uriTemplate: string, - argumentName: string, - argumentValue: string - ): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> { - const connection = this.connections.get(serverName); - - if (!connection) { - console.warn(`[MCPStore] Server "${serverName}" is not connected`); - return null; - } - - if (!connection.serverCapabilities?.completions) { - return null; - } - - return MCPService.complete( - connection, - { type: MCPRefType.RESOURCE, uri: uriTemplate }, - { name: argumentName, value: argumentValue } - ); - } - - /** - * Read a resource by an arbitrary URI (e.g., one expanded from a template). - * Unlike readResource(), this does not require the URI to be in the resources list. - */ - async readResourceByUri(serverName: string, uri: string): Promise { - const connection = this.connections.get(serverName); - - if (!connection) { - console.error(`[MCPStore] No connection found for server: ${serverName}`); - - return null; - } - - try { - const result = await MCPService.readResource(connection, uri); - - return result.contents; - } catch (error) { - console.error(`[MCPStore] Failed to read resource ${uri}:`, error); - - return null; - } - } - - private parseHeaders(headersJson?: string): Record | undefined { - if (!headersJson?.trim()) { - return undefined; - } - - try { - const parsed = JSON.parse(headersJson); - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) - return parsed as Record; - } catch { - console.warn('[MCPStore] Failed to parse custom headers JSON:', headersJson); - } - - return undefined; - } - - async runHealthChecksForServers( - servers: { - id: string; - enabled: boolean; - url: string; - requestTimeoutSeconds: number; - headers?: string; - }[], - skipIfChecked = true, - promoteToActive = false - ): Promise { - const serversToCheck = skipIfChecked - ? servers.filter((s) => !this.hasHealthCheck(s.id) && s.url.trim()) - : servers.filter((s) => s.url.trim()); - - if (serversToCheck.length === 0) { - return; - } - - const BATCH_SIZE = 5; - for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) { - const batch = serversToCheck.slice(i, i + BATCH_SIZE); - await Promise.allSettled(batch.map((server) => this.runHealthCheck(server, promoteToActive))); - } - } - - /** - * Check if a server already has an active connection that can be reused. - * Returns the existing connection if available. - */ - getExistingConnection(serverId: string): MCPConnection | undefined { - return this.connections.get(serverId); - } - - /** - * Run a health check for a server. - * If the server already has an active connection, reuses it instead of creating a new one. - * If promoteToActive is true and server is enabled, the connection will be kept - * and promoted to an active connection instead of being disconnected. - */ - async runHealthCheck(server: HealthCheckParams, promoteToActive = false): Promise { - // Check if we already have an active connection for this server - const existingConnection = this.connections.get(server.id); - if (existingConnection) { - // Reuse existing connection - just refresh tools list - try { - const tools = await MCPService.listTools(existingConnection); - const capabilities = this.#buildCapabilitiesInfo( - existingConnection.serverCapabilities, - existingConnection.clientCapabilities - ); - this.updateHealthCheck(server.id, { - status: HealthCheckStatus.SUCCESS, - tools: tools.map((tool) => ({ - name: tool.name, - description: tool.description, - title: tool.title - })), - serverInfo: existingConnection.serverInfo, - capabilities, - transportType: existingConnection.transportType, - protocolVersion: existingConnection.protocolVersion, - instructions: existingConnection.instructions, - connectionTimeMs: existingConnection.connectionTimeMs, - logs: [] - }); - return; - } catch (error) { - console.warn( - `[MCPStore] Failed to reuse connection for ${server.id}, creating new one:`, - error - ); - // Connection may be stale, remove it and create new one - this.connections.delete(server.id); - } - } - - const trimmedUrl = server.url.trim(); - const logs: MCPConnectionLog[] = []; - let currentPhase: MCPConnectionPhase = MCPConnectionPhase.IDLE; - - if (!trimmedUrl) { - this.updateHealthCheck(server.id, { - status: HealthCheckStatus.ERROR, - message: 'Please enter a server URL first.', - logs: [] - }); - return; - } - - this.updateHealthCheck(server.id, { - status: HealthCheckStatus.CONNECTING, - phase: MCPConnectionPhase.TRANSPORT_CREATING, - logs: [] - }); - - const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000); - const headers = this.parseHeaders(server.headers); - - try { - const serverConfig: MCPServerConfig = { - url: trimmedUrl, - transport: detectMcpTransportFromUrl(trimmedUrl), - handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs, - requestTimeoutMs: timeoutMs, - headers, - useProxy: server.useProxy - }; - - // Store config for reconnection - this.serverConfigs.set(server.id, serverConfig); - - const connection = await MCPService.connect( - server.id, - serverConfig, - DEFAULT_MCP_CONFIG.clientInfo, - DEFAULT_MCP_CONFIG.capabilities, - (phase, log) => { - currentPhase = phase; - logs.push(log); - this.updateHealthCheck(server.id, { - status: HealthCheckStatus.CONNECTING, - phase, - logs: [...logs] - }); - - // Handle WebSocket disconnection - if (phase === MCPConnectionPhase.DISCONNECTED && promoteToActive) { - console.log( - `[MCPStore][${server.id}] Connection lost during health check, starting auto-reconnect` - ); - this.autoReconnect(server.id); - } - } - ); - - const tools = connection.tools.map((tool) => ({ - name: tool.name, - description: tool.description, - title: tool.title - })); - - const capabilities = this.#buildCapabilitiesInfo( - connection.serverCapabilities, - connection.clientCapabilities - ); - - this.updateHealthCheck(server.id, { - status: HealthCheckStatus.SUCCESS, - tools, - serverInfo: connection.serverInfo, - capabilities, - transportType: connection.transportType, - protocolVersion: connection.protocolVersion, - instructions: connection.instructions, - connectionTimeMs: connection.connectionTimeMs, - logs - }); - - // Promote to active connection or disconnect - if (promoteToActive && server.enabled) { - this.promoteHealthCheckToConnection(server.id, connection); - } else { - await MCPService.disconnect(connection); - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error occurred'; - - if (logs.at(-1)?.phase !== MCPConnectionPhase.ERROR) { - logs.push({ - timestamp: new Date(), - phase: MCPConnectionPhase.ERROR, - message: `Connection failed: ${message}`, - level: MCPLogLevel.ERROR - }); - } - - this.updateHealthCheck(server.id, { - status: HealthCheckStatus.ERROR, - message, - phase: currentPhase, - logs - }); - } - } - - /** - * Promote a health check connection to an active connection. - * This avoids the need to reconnect when the server is needed for agentic flows. - */ - private promoteHealthCheckToConnection(serverId: string, connection: MCPConnection): void { - // Register tools from the connection - for (const tool of connection.tools) { - if (this.toolsIndex.has(tool.name)) { - console.warn( - `[MCPStore] Tool name conflict during promotion: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${serverId}". Using tool from "${serverId}".` - ); - } - this.toolsIndex.set(tool.name, serverId); - } - - // Add to active connections - this.connections.set(serverId, connection); - - // Update state - this.updateState({ - toolCount: this.toolsIndex.size, - connectedServers: Array.from(this.connections.keys()) - }); - } - - getServersStatus(): ServerStatus[] { - const statuses: ServerStatus[] = []; - - for (const [name, connection] of this.connections) { - statuses.push({ - name, - isConnected: true, - toolCount: connection.tools.length, - error: undefined - }); - } - - return statuses; - } - - /** - * Get aggregated server instructions from all connected servers. - * Returns an array of { serverName, serverTitle, instructions } objects. - */ - getServerInstructions(): Array<{ - serverName: string; - serverTitle?: string; - instructions: string; - }> { - const results: Array<{ serverName: string; serverTitle?: string; instructions: string }> = []; - - for (const [serverName, connection] of this.connections) { - if (connection.instructions) { - results.push({ - serverName, - serverTitle: connection.serverInfo?.title || connection.serverInfo?.name, - instructions: connection.instructions - }); - } - } - - return results; - } - - /** - * Get server instructions from health check results (for display before active connection). - * Useful for showing instructions in settings UI. - */ - getHealthCheckInstructions(): Array<{ - serverId: string; - serverTitle?: string; - instructions: string; - }> { - const results: Array<{ serverId: string; serverTitle?: string; instructions: string }> = []; - - for (const [serverId, state] of Object.entries(this._healthChecks)) { - if (state.status === HealthCheckStatus.SUCCESS && state.instructions) { - results.push({ - serverId, - serverTitle: state.serverInfo?.title || state.serverInfo?.name, - instructions: state.instructions - }); - } - } - - return results; - } - - /** - * Check if any connected server has instructions. - */ - hasServerInstructions(): boolean { - for (const connection of this.connections.values()) { - if (connection.instructions) { - return true; - } - } - - return false; - } - - /** - * - * - * Resources Operations - * - * - */ - - /** - * Check if any enabled server with successful health check supports resources. - * Uses health check state since servers may not have active connections until - * the user actually sends a message or uses prompts. - * @param perChatOverrides - Per-chat server overrides to filter by enabled servers. - * If provided (even empty array), only checks enabled servers. - * If undefined, checks all servers with successful health checks. - */ - hasResourcesCapability(perChatOverrides?: McpServerOverride[]): boolean { - // If perChatOverrides is provided (even empty array), filter by enabled servers - if (perChatOverrides !== undefined) { - const enabledServerIds = new Set( - perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId) - ); - // No enabled servers = no capability - if (enabledServerIds.size === 0) { - return false; - } - - // Check health check states for enabled servers with resources capability - for (const [serverId, state] of Object.entries(this._healthChecks)) { - if (!enabledServerIds.has(serverId)) continue; - if ( - state.status === HealthCheckStatus.SUCCESS && - state.capabilities?.server?.resources !== undefined - ) { - return true; - } - } - - // Also check active connections as fallback - for (const [serverName, connection] of this.connections) { - if (!enabledServerIds.has(serverName)) continue; - if (MCPService.supportsResources(connection)) { - return true; - } - } - - return false; - } - - // No overrides provided - check all servers (global mode) - for (const state of Object.values(this._healthChecks)) { - if ( - state.status === HealthCheckStatus.SUCCESS && - state.capabilities?.server?.resources !== undefined - ) { - return true; - } - } - - for (const connection of this.connections.values()) { - if (MCPService.supportsResources(connection)) { - return true; - } - } - - return false; - } - - /** - * Get list of servers that support resources. - * Checks active connections first, then health check state as fallback. - */ - getServersWithResources(): string[] { - const servers: string[] = []; - - // Check active connections - for (const [name, connection] of this.connections) { - if (MCPService.supportsResources(connection) && !servers.includes(name)) { - servers.push(name); - } - } - - // Also check health check states for servers not yet connected - for (const [serverId, state] of Object.entries(this._healthChecks)) { - if ( - !servers.includes(serverId) && - state.status === HealthCheckStatus.SUCCESS && - state.capabilities?.server?.resources !== undefined - ) { - servers.push(serverId); - } - } - - return servers; - } - - /** - * Fetch resources from all connected servers that support them. - * Updates mcpResourceStore with the results. - * @param forceRefresh - If true, bypass cache and fetch fresh data - */ - async fetchAllResources(forceRefresh: boolean = false): Promise { - const serversWithResources = this.getServersWithResources(); - if (serversWithResources.length === 0) { - return; - } - - // Check if we have cached resources and they're recent (unless force refresh) - if (!forceRefresh) { - const allServersCached = serversWithResources.every((serverName) => { - const serverRes = mcpResourceStore.getServerResources(serverName); - if (!serverRes || !serverRes.lastFetched) { - return false; - } - - // Cache is valid for 5 minutes - const age = Date.now() - serverRes.lastFetched.getTime(); - - return age < DEFAULT_CACHE_TTL_MS; - }); - - if (allServersCached) { - console.log('[MCPStore] Using cached resources'); - - return; - } - } - - mcpResourceStore.setLoading(true); - - try { - await Promise.all( - serversWithResources.map((serverName) => this.fetchServerResources(serverName)) - ); - } finally { - mcpResourceStore.setLoading(false); - } - } - - /** - * Fetch resources from a specific server. - * Updates mcpResourceStore with the results. - */ - async fetchServerResources(serverName: string): Promise { - const connection = this.connections.get(serverName); - if (!connection) { - console.warn(`[MCPStore] No connection found for server: ${serverName}`); - return; - } - - if (!MCPService.supportsResources(connection)) { - return; - } - - mcpResourceStore.setServerLoading(serverName, true); - - try { - const [resources, templates] = await Promise.all([ - MCPService.listAllResources(connection), - MCPService.listAllResourceTemplates(connection) - ]); - - mcpResourceStore.setServerResources(serverName, resources, templates); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - mcpResourceStore.setServerError(serverName, message); - console.error(`[MCPStore][${serverName}] Failed to fetch resources:`, error); - } - } - - /** - * Read resource content from a server. - * Caches the result in mcpResourceStore. - */ - async readResource(uri: string): Promise { - // Check cache first - const cached = mcpResourceStore.getCachedContent(uri); - if (cached) { - return cached.content; - } - - // Find which server has this resource - const serverName = mcpResourceStore.findServerForUri(uri); - if (!serverName) { - console.error(`[MCPStore] No server found for resource URI: ${uri}`); - - return null; - } - - const connection = this.connections.get(serverName); - if (!connection) { - console.error(`[MCPStore] No connection found for server: ${serverName}`); - - return null; - } - - try { - const result = await MCPService.readResource(connection, uri); - const resourceInfo = mcpResourceStore.findResourceByUri(uri); - - if (resourceInfo) { - mcpResourceStore.cacheResourceContent(resourceInfo, result.contents); - } - - return result.contents; - } catch (error) { - console.error(`[MCPStore] Failed to read resource ${uri}:`, error); - - return null; - } - } - - /** - * Subscribe to resource updates. - */ - async subscribeToResource(uri: string): Promise { - const serverName = mcpResourceStore.findServerForUri(uri); - if (!serverName) { - console.error(`[MCPStore] No server found for resource URI: ${uri}`); - - return false; - } - - const connection = this.connections.get(serverName); - if (!connection) { - console.error(`[MCPStore] No connection found for server: ${serverName}`); - - return false; - } - - if (!MCPService.supportsResourceSubscriptions(connection)) { - return false; - } - - try { - await MCPService.subscribeResource(connection, uri); - mcpResourceStore.addSubscription(uri, serverName); - - return true; - } catch (error) { - console.error(`[MCPStore] Failed to subscribe to resource ${uri}:`, error); - - return false; - } - } - - /** - * Unsubscribe from resource updates. - */ - async unsubscribeFromResource(uri: string): Promise { - const serverName = mcpResourceStore.findServerForUri(uri); - if (!serverName) { - console.error(`[MCPStore] No server found for resource URI: ${uri}`); - - return false; - } - - const connection = this.connections.get(serverName); - if (!connection) { - console.error(`[MCPStore] No connection found for server: ${serverName}`); - - return false; - } - - try { - await MCPService.unsubscribeResource(connection, uri); - mcpResourceStore.removeSubscription(uri); - - return true; - } catch (error) { - console.error(`[MCPStore] Failed to unsubscribe from resource ${uri}:`, error); - - return false; - } - } - - /** - * Add a resource as attachment to chat context. - * Automatically fetches content if not cached. - */ - async attachResource(uri: string): Promise { - const resourceInfo = mcpResourceStore.findResourceByUri(uri); - if (!resourceInfo) { - console.error(`[MCPStore] Resource not found: ${uri}`); - - return null; - } - - // Check if already attached - if (mcpResourceStore.isAttached(uri)) { - return null; - } - - // Add attachment (initially loading) - const attachment = mcpResourceStore.addAttachment(resourceInfo); - - // Fetch content - try { - const content = await this.readResource(uri); - - if (content) { - mcpResourceStore.updateAttachmentContent(attachment.id, content); - } else { - mcpResourceStore.updateAttachmentError(attachment.id, 'Failed to read resource'); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - mcpResourceStore.updateAttachmentError(attachment.id, message); - } - - return mcpResourceStore.getAttachment(attachment.id) ?? null; - } - - /** - * Remove a resource attachment from chat context. - */ - removeResourceAttachment(attachmentId: string): void { - mcpResourceStore.removeAttachment(attachmentId); - } - - /** - * Clear all resource attachments. - */ - clearResourceAttachments(): void { - mcpResourceStore.clearAttachments(); - } - - /** - * Get formatted resource context for chat. - */ - getResourceContextForChat(): string { - return mcpResourceStore.formatAttachmentsForContext(); - } - - /** - * Convert current resource attachments to DatabaseMessageExtra[] and clear them. - * Called during message send to persist resources with the user message. - */ - consumeResourceAttachmentsAsExtras(): DatabaseMessageExtraMcpResource[] { - const extras = mcpResourceStore.toMessageExtras(); - if (extras.length > 0) { - mcpResourceStore.clearAttachments(); - } - return extras; - } -} - -export const mcpStore = new MCPStore(); - -export const mcpIsInitializing = () => mcpStore.isInitializing; -export const mcpIsInitialized = () => mcpStore.isInitialized; -export const mcpError = () => mcpStore.error; -export const mcpIsEnabled = () => mcpStore.isEnabled; -export const mcpIsProxyAvailable = () => mcpStore.isProxyAvailable; -export const mcpAvailableTools = () => mcpStore.availableTools; -export const mcpConnectedServerCount = () => mcpStore.connectedServerCount; -export const mcpConnectedServerNames = () => mcpStore.connectedServerNames; -export const mcpToolCount = () => mcpStore.toolCount; -export const mcpServerInstructions = () => mcpStore.getServerInstructions(); -export const mcpHasServerInstructions = () => mcpStore.hasServerInstructions(); - -// Resources exports -export const mcpHasResourcesCapability = () => mcpStore.hasResourcesCapability(); -export const mcpServersWithResources = () => mcpStore.getServersWithResources(); -export const mcpResourceContext = () => mcpStore.getResourceContextForChat(); diff --git a/tools/server/webui/src/lib/stores/models.svelte.ts b/tools/server/webui/src/lib/stores/models.svelte.ts deleted file mode 100644 index 1f49f09ea..000000000 --- a/tools/server/webui/src/lib/stores/models.svelte.ts +++ /dev/null @@ -1,836 +0,0 @@ -import { SvelteMap, SvelteSet } from 'svelte/reactivity'; -import { toast } from 'svelte-sonner'; -import { ServerModelStatus, ModelModality } from '$lib/enums'; -import { ModelsService } from '$lib/services/models.service'; -import { PropsService } from '$lib/services/props.service'; -import { serverStore } from '$lib/stores/server.svelte'; -import { TTLCache } from '$lib/utils'; -import { - MODEL_PROPS_CACHE_TTL_MS, - MODEL_PROPS_CACHE_MAX_ENTRIES, - FAVORITE_MODELS_LOCALSTORAGE_KEY -} from '$lib/constants'; -import { conversationsStore } from '$lib/stores/conversations.svelte'; - -/** - * modelsStore - Reactive store for model management in both MODEL and ROUTER modes - * - * This store manages: - * - Available models list - * - Selected model for new conversations - * - Loaded models tracking (ROUTER mode) - * - Model usage tracking per conversation - * - Automatic unloading of unused models - * - * **Architecture & Relationships:** - * - **ModelsService**: Stateless service for model API communication - * - **PropsService**: Stateless service for props/modalities fetching - * - **modelsStore** (this class): Reactive store for model state - * - **conversationsStore**: Tracks which conversations use which models - * - * **API Inconsistency Workaround:** - * In MODEL mode, `/props` returns modalities for the single model. - * In ROUTER mode, `/props` has no modalities - must use `/props?model=` per model. - * This store normalizes this behavior so consumers don't need to know the server mode. - * - * **Key Features:** - * - **MODEL mode**: Single model, always loaded - * - **ROUTER mode**: Multi-model with load/unload capability - * - **Auto-unload**: Automatically unloads models not used by any conversation - * - **Lazy loading**: ensureModelLoaded() loads models on demand - */ -class ModelsStore { - /** - * - * - * State - * - * - */ - - models = $state([]); - routerModels = $state([]); - loading = $state(false); - updating = $state(false); - error = $state(null); - selectedModelId = $state(null); - selectedModelName = $state(null); - - // dedup concurrent fetch() callers, all awaiters share the same inflight promise - // without this, ?model= URL handler raced an in-progress fetch and saw an empty list - private inflightFetch: Promise | null = null; - - private modelUsage = $state>>(new Map()); - private modelLoadingStates = new SvelteMap(); - - favoriteModelIds = $state>(this.loadFavoritesFromStorage()); - - /** - * Model-specific props cache with TTL - * Key: modelId, Value: props data including modalities - * TTL: 10 minutes - props don't change frequently - */ - private modelPropsCache = new TTLCache({ - ttlMs: MODEL_PROPS_CACHE_TTL_MS, - maxEntries: MODEL_PROPS_CACHE_MAX_ENTRIES - }); - private modelPropsFetching = $state>(new Set()); - - /** - * Version counter for props cache - used to trigger reactivity when props are updated - */ - propsCacheVersion = $state(0); - - /** - * - * - * Computed Getters - * - * - */ - - get selectedModel(): ModelOption | null { - if (!this.selectedModelId) return null; - return this.models.find((model) => model.id === this.selectedModelId) ?? null; - } - - get loadedModelIds(): string[] { - return this.routerModels - .filter( - (m) => - m.status.value === ServerModelStatus.LOADED || - m.status.value === ServerModelStatus.SLEEPING - ) - .map((m) => m.id); - } - - get loadingModelIds(): string[] { - return Array.from(this.modelLoadingStates.entries()) - .filter(([, loading]) => loading) - .map(([id]) => id); - } - - /** - * Get model name in MODEL mode (single model). - * Extracts from model_path or model_alias from server props. - * In ROUTER mode, returns null (model is per-conversation). - */ - get singleModelName(): string | null { - if (serverStore.isRouterMode) return null; - - const props = serverStore.props; - if (props?.model_alias) return props.model_alias; - if (!props?.model_path) return null; - - return props.model_path.split(/(\\|\/)/).pop() || null; - } - - /** - * - * - * Modalities - * - * - */ - - /** - * Get modalities for a specific model - * Returns cached modalities from model props - */ - getModelModalities(modelId: string): ModelModalities | null { - const model = this.models.find((m) => m.model === modelId || m.id === modelId); - if (model?.modalities) { - return model.modalities; - } - - const props = this.modelPropsCache.get(modelId); - if (props?.modalities) { - return { - vision: props.modalities.vision ?? false, - audio: props.modalities.audio ?? false - }; - } - - return null; - } - - /** - * Check if a model supports vision modality - */ - modelSupportsVision(modelId: string): boolean { - return this.getModelModalities(modelId)?.vision ?? false; - } - - /** - * Check if a model supports audio modality - */ - modelSupportsAudio(modelId: string): boolean { - return this.getModelModalities(modelId)?.audio ?? false; - } - - /** - * Get model modalities as an array of ModelModality enum values - */ - getModelModalitiesArray(modelId: string): ModelModality[] { - const modalities = this.getModelModalities(modelId); - if (!modalities) return []; - - const result: ModelModality[] = []; - - if (modalities.vision) result.push(ModelModality.VISION); - if (modalities.audio) result.push(ModelModality.AUDIO); - - return result; - } - - /** - * Get props for a specific model (from cache) - */ - getModelProps(modelId: string): ApiLlamaCppServerProps | null { - return this.modelPropsCache.get(modelId); - } - - /** - * Get context size (n_ctx) for a specific model from cached props - */ - getModelContextSize(modelId: string): number | null { - const props = this.getModelProps(modelId); - const nCtx = props?.default_generation_settings?.n_ctx; - - return typeof nCtx === 'number' ? nCtx : null; - } - - /** - * Get context size for the currently selected model or null if no model is selected - */ - get selectedModelContextSize(): number | null { - if (!this.selectedModelName) return null; - return this.getModelContextSize(this.selectedModelName); - } - - /** - * Check if props are being fetched for a model - */ - isModelPropsFetching(modelId: string): boolean { - return this.modelPropsFetching.has(modelId); - } - - /** - * - * - * Status Queries - * - * - */ - - isModelLoaded(modelId: string): boolean { - const model = this.routerModels.find((m) => m.id === modelId); - return ( - model?.status.value === ServerModelStatus.LOADED || - model?.status.value === ServerModelStatus.SLEEPING || - false - ); - } - - isModelOperationInProgress(modelId: string): boolean { - return this.modelLoadingStates.get(modelId) ?? false; - } - - getModelStatus(modelId: string): ServerModelStatus | null { - const model = this.routerModels.find((m) => m.id === modelId); - return model?.status.value ?? null; - } - - getModelUsage(modelId: string): SvelteSet { - return this.modelUsage.get(modelId) ?? new SvelteSet(); - } - - isModelInUse(modelId: string): boolean { - const usage = this.modelUsage.get(modelId); - return usage !== undefined && usage.size > 0; - } - - /** - * - * - * Data Fetching - * - * - */ - - /** - * Fetch list of models from server and detect server role - * Also fetches modalities for MODEL mode (single model) - */ - async fetch(force = false): Promise { - if (this.inflightFetch) return this.inflightFetch; - if (this.models.length > 0 && !force) return; - - this.inflightFetch = this.runFetch(); - try { - await this.inflightFetch; - } finally { - this.inflightFetch = null; - } - } - - private async runFetch(): Promise { - this.loading = true; - this.error = null; - - try { - if (!serverStore.props) { - await serverStore.fetch(); - } - - const response = await ModelsService.list(); - - const models: ModelOption[] = response.data.map((item: ApiModelDataEntry, index: number) => { - const details = response.models?.[index]; - const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : []; - const displayNameSource = - details?.name && details.name.trim().length > 0 ? details.name : item.id; - const displayName = this.toDisplayName(displayNameSource); - const modelId = details?.model || item.id; - - return { - id: item.id, - name: displayName, - model: modelId, - description: details?.description, - capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)), - details: details?.details, - meta: item.meta ?? null, - parsedId: ModelsService.parseModelId(modelId), - aliases: item.aliases ?? [], - tags: item.tags ?? [] - } satisfies ModelOption; - }); - - this.models = models; - - // WORKAROUND: In MODEL mode, /props returns modalities for the single model, - // but /v1/models doesn't include modalities. We bridge this gap here. - const serverProps = serverStore.props; - if (serverStore.isModelMode && this.models.length > 0 && serverProps?.modalities) { - const modalities: ModelModalities = { - vision: serverProps.modalities.vision ?? false, - audio: serverProps.modalities.audio ?? false - }; - this.modelPropsCache.set(this.models[0].model, serverProps); - this.models = this.models.map((model, index) => - index === 0 ? { ...model, modalities } : model - ); - } - } catch (error) { - this.models = []; - this.error = error instanceof Error ? error.message : 'Failed to load models'; - throw error; - } finally { - this.loading = false; - } - } - - /** - * Fetch router models with full metadata (ROUTER mode only) - * This fetches the /models endpoint which returns status info for each model - */ - async fetchRouterModels(): Promise { - try { - const response = await ModelsService.listRouter(); - this.routerModels = response.data; - await this.fetchModalitiesForLoadedModels(); - - const o = this.models.filter((option) => { - const modelProps = this.getModelProps(option.model); - - return modelProps?.webui !== false; - }); - - if (o.length === 1 && this.isModelLoaded(o[0].model)) { - this.selectModelById(o[0].id); - } - } catch (error) { - console.warn('Failed to fetch router models:', error); - this.routerModels = []; - } - } - - /** - * Fetch props for a specific model from /props endpoint - * Uses caching to avoid redundant requests - * - * In ROUTER mode, this will only fetch props if the model is loaded, - * since unloaded models return 400 from /props endpoint. - * - * @param modelId - Model identifier to fetch props for - * @returns Props data or null if fetch failed or model not loaded - */ - async fetchModelProps(modelId: string): Promise { - const cached = this.modelPropsCache.get(modelId); - if (cached) return cached; - - if (serverStore.isRouterMode && !this.isModelLoaded(modelId)) { - return null; - } - - if (this.modelPropsFetching.has(modelId)) return null; - - this.modelPropsFetching.add(modelId); - - try { - const props = await PropsService.fetchForModel(modelId); - this.modelPropsCache.set(modelId, props); - return props; - } catch (error) { - console.warn(`Failed to fetch props for model ${modelId}:`, error); - return null; - } finally { - this.modelPropsFetching.delete(modelId); - } - } - - /** - * Fetch modalities for all loaded models from /props endpoint - * This updates the modalities field in models array - */ - async fetchModalitiesForLoadedModels(): Promise { - const loadedModelIds = this.loadedModelIds; - if (loadedModelIds.length === 0) return; - - const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId)); - - try { - const results = await Promise.all(propsPromises); - - // Update models with modalities - this.models = this.models.map((model) => { - const modelIndex = loadedModelIds.indexOf(model.model); - if (modelIndex === -1) return model; - - const props = results[modelIndex]; - if (!props?.modalities) return model; - - const modalities: ModelModalities = { - vision: props.modalities.vision ?? false, - audio: props.modalities.audio ?? false - }; - - return { ...model, modalities }; - }); - - this.propsCacheVersion++; - } catch (error) { - console.warn('Failed to fetch modalities for loaded models:', error); - } - } - - /** - * Gets the model name from the last assistant message in the active conversation. - * Iterates backward through messages to find the most recent message with a model. - * Used by both the chat page and settings page to maintain model consistency. - * @returns The model name or null if not found - */ - getModelFromLastAssistantResponse(): string | null { - const messages = conversationsStore.activeMessages; - if (!messages || messages.length === 0) return null; - - // Iterate backward to find the last message with a model - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].model) { - return messages[i].model; - } - } - - return null; - } - - /** - * Auto-selects the model from the last assistant response if available and loaded. - * Returns true if a model was selected, false otherwise. - * This is used by the chat page to maintain model consistency across page navigation. - */ - async selectModelFromLastAssistantResponse(): Promise { - const lastModel = this.getModelFromLastAssistantResponse(); - if (!lastModel) return false; - - // Skip if already selected - if (this.selectedModelName === lastModel) return false; - - const matchingModel = this.models.find((option) => option.model === lastModel); - if (!matchingModel) return false; - - if (!this.isModelLoaded(lastModel)) { - console.log('[modelsStore] last assistant model not loaded:', lastModel); - return false; - } - - try { - await this.selectModelById(matchingModel.id); - console.log(`[modelsStore] Automatically selected model: ${lastModel} from last message`); - return true; - } catch (error) { - console.warn('[modelsStore] Failed to automatically select model from last message:', error); - return false; - } - } - - /** - * Auto-selects the first available model if none is selected, and fetches its props. - * Prioritizes: - * 1. Model from active conversation's last assistant response (if loaded) - * 2. Model from active conversation's last assistant response (if not loaded) - * 3. First loaded model (not from active conversation) - * 4. First available model - * This is used to ensure default values are populated in settings pages. - */ - async ensureFirstModelSelected(): Promise { - if (this.selectedModelName) return; - - // Filter models that are visible in webui - const availableModels = this.models.filter((option) => { - const modelProps = this.getModelProps(option.model); - return modelProps?.webui !== false; - }); - - if (availableModels.length === 0) return; - - // Try to select model from last assistant response first - const lastModel = this.getModelFromLastAssistantResponse(); - if (lastModel) { - const lastModelOption = availableModels.find((m) => m.model === lastModel); - if (lastModelOption) { - await this.selectModelById(lastModelOption.id); - if (this.isModelLoaded(lastModel)) { - await this.fetchModelProps(lastModel); - } - return; - } - } - - // Try to find a loaded model first - const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model)); - if (loadedModel) { - await this.selectModelById(loadedModel.id); - await this.fetchModelProps(loadedModel.model); - return; - } - - // Fall back to the first available model - const firstModel = availableModels[0]; - await this.selectModelById(firstModel.id); - // Don't fetch props for unloaded models (will fail in ROUTER mode) - } - - /** - * Update modalities for a specific model - * Called when a model is loaded or when we need fresh modality data - */ - async updateModelModalities(modelId: string): Promise { - try { - const props = await this.fetchModelProps(modelId); - if (!props?.modalities) return; - - const modalities: ModelModalities = { - vision: props.modalities.vision ?? false, - audio: props.modalities.audio ?? false - }; - - this.models = this.models.map((model) => - model.model === modelId ? { ...model, modalities } : model - ); - - this.propsCacheVersion++; - } catch (error) { - console.warn(`Failed to update modalities for model ${modelId}:`, error); - } - } - - /** - * - * - * Model Selection - * - * - */ - - /** - * Select a model for new conversations - */ - async selectModelById(modelId: string): Promise { - if (!modelId || this.updating) return; - if (this.selectedModelId === modelId) return; - - const option = this.models.find((model) => model.id === modelId); - if (!option) throw new Error('Selected model is not available'); - - this.updating = true; - this.error = null; - - try { - this.selectedModelId = option.id; - this.selectedModelName = option.model; - } finally { - this.updating = false; - } - } - - /** - * Select a model by its model name (used for syncing with conversation model) - * @param modelName - Model name to select (e.g., "ggml-org/GLM-4.7-Flash-GGUF") - */ - selectModelByName(modelName: string): void { - const option = this.models.find((model) => model.model === modelName); - if (option) { - this.selectedModelId = option.id; - this.selectedModelName = option.model; - } - } - - clearSelection(): void { - this.selectedModelId = null; - this.selectedModelName = null; - } - - findModelByName(modelName: string): ModelOption | null { - return this.models.find((model) => model.model === modelName) ?? null; - } - - findModelById(modelId: string): ModelOption | null { - return this.models.find((model) => model.id === modelId) ?? null; - } - - hasModel(modelName: string): boolean { - return this.models.some((model) => model.model === modelName); - } - - /** - * - * - * Loading/Unloading Models - * - * - */ - - /** - * WORKAROUND: Polling for model status after load/unload operations. - * - * Currently, the `/models/load` and `/models/unload` endpoints return success - * before the operation actually completes on the server. This means an immediate - * request to `/models` returns stale status (e.g., "loading" after load request, - * "loaded" after unload request). - * - * TODO: Remove this polling once llama-server properly waits for the operation - * to complete before returning success from `/load` and `/unload` endpoints. - * At that point, a single `fetchRouterModels()` call after the operation will - * be sufficient to get the correct status. - */ - - /** Polling interval in ms for checking model status */ - private static readonly STATUS_POLL_INTERVAL = 500; - - /** - * Poll for expected model status after load/unload operation. - * Keeps polling indefinitely until the model reaches the expected status or fails. - * - * @param modelId - Model identifier to check - * @param expectedStatus - Expected status to wait for - * @throws Error if model reaches FAILED status - */ - private async pollForModelStatus( - modelId: string, - expectedStatus: ServerModelStatus - ): Promise { - let attempt = 0; - while (true) { - await this.fetchRouterModels(); - - const currentStatus = this.getModelStatus(modelId); - if (currentStatus === expectedStatus) { - return; - } - - if (currentStatus === ServerModelStatus.FAILED) { - throw new Error( - `Model failed to ${expectedStatus === ServerModelStatus.LOADED ? 'load' : 'unload'}` - ); - } - - if ( - expectedStatus === ServerModelStatus.LOADED && - currentStatus === ServerModelStatus.UNLOADED && - attempt > 2 - ) { - throw new Error('Model was unloaded unexpectedly during loading'); - } - - attempt++; - await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL)); - } - } - - /** - * Load a model (ROUTER mode) - * @param modelId - Model identifier to load - */ - async loadModel(modelId: string): Promise { - if (this.isModelLoaded(modelId)) { - return; - } - - if (this.modelLoadingStates.get(modelId)) return; - - this.modelLoadingStates.set(modelId, true); - this.error = null; - - try { - await ModelsService.load(modelId); - await this.pollForModelStatus(modelId, ServerModelStatus.LOADED); - - await this.updateModelModalities(modelId); - toast.success(`Model loaded: ${this.toDisplayName(modelId)}`); - } catch (error) { - this.error = error instanceof Error ? error.message : 'Failed to load model'; - toast.error(`Failed to load model: ${this.toDisplayName(modelId)}`); - throw error; - } finally { - this.modelLoadingStates.set(modelId, false); - } - } - - /** - * Unload a model (ROUTER mode) - * @param modelId - Model identifier to unload - */ - async unloadModel(modelId: string): Promise { - if (!this.isModelLoaded(modelId)) { - return; - } - - if (this.modelLoadingStates.get(modelId)) return; - - this.modelLoadingStates.set(modelId, true); - this.error = null; - - try { - await ModelsService.unload(modelId); - - await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED); - toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`); - } catch (error) { - this.error = error instanceof Error ? error.message : 'Failed to unload model'; - toast.error(`Failed to unload model: ${this.toDisplayName(modelId)}`); - throw error; - } finally { - this.modelLoadingStates.set(modelId, false); - } - } - - /** - * Ensure a model is loaded before use - * @param modelId - Model identifier to ensure is loaded - */ - async ensureModelLoaded(modelId: string): Promise { - if (this.isModelLoaded(modelId)) { - return; - } - - await this.loadModel(modelId); - } - - /** - * - * - * Favorites - * - * - */ - - isFavorite(modelId: string): boolean { - return this.favoriteModelIds.has(modelId); - } - - toggleFavorite(modelId: string): void { - const next = new SvelteSet(this.favoriteModelIds); - - if (next.has(modelId)) { - next.delete(modelId); - } else { - next.add(modelId); - } - - this.favoriteModelIds = next; - - try { - localStorage.setItem(FAVORITE_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next])); - } catch { - toast.error('Failed to save favorite models to local storage'); - } - } - - private loadFavoritesFromStorage(): Set { - try { - const raw = localStorage.getItem(FAVORITE_MODELS_LOCALSTORAGE_KEY); - - return raw ? new Set(JSON.parse(raw) as string[]) : new Set(); - } catch { - toast.error('Failed to load favorite models from local storage'); - - return new Set(); - } - } - - /** - * - * - * Utilities - * - * - */ - - private toDisplayName(id: string): string { - const segments = id.split(/\\|\//); - const candidate = segments.pop(); - - return candidate && candidate.trim().length > 0 ? candidate : id; - } - - clear(): void { - this.models = []; - this.routerModels = []; - this.loading = false; - this.updating = false; - this.error = null; - this.selectedModelId = null; - this.selectedModelName = null; - this.modelUsage.clear(); - this.modelLoadingStates.clear(); - this.modelPropsCache.clear(); - this.modelPropsFetching.clear(); - } - - /** - * Prune expired entries from caches. - * Call periodically for proactive memory cleanup. - */ - pruneExpiredCache(): number { - return this.modelPropsCache.prune(); - } -} - -export const modelsStore = new ModelsStore(); - -export const modelOptions = () => modelsStore.models; -export const routerModels = () => modelsStore.routerModels; -export const modelsLoading = () => modelsStore.loading; -export const modelsUpdating = () => modelsStore.updating; -export const modelsError = () => modelsStore.error; -export const selectedModelId = () => modelsStore.selectedModelId; -export const selectedModelName = () => modelsStore.selectedModelName; -export const selectedModelOption = () => modelsStore.selectedModel; -export const loadedModelIds = () => modelsStore.loadedModelIds; -export const loadingModelIds = () => modelsStore.loadingModelIds; -export const propsCacheVersion = () => modelsStore.propsCacheVersion; -export const singleModelName = () => modelsStore.singleModelName; -export const selectedModelContextSize = () => modelsStore.selectedModelContextSize; -export const favoriteModelIds = () => modelsStore.favoriteModelIds; diff --git a/tools/server/webui/src/lib/stores/permissions.svelte.ts b/tools/server/webui/src/lib/stores/permissions.svelte.ts deleted file mode 100644 index f44575b54..000000000 --- a/tools/server/webui/src/lib/stores/permissions.svelte.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY } from '$lib/constants'; -import { SvelteSet } from 'svelte/reactivity'; - -class PermissionsStore { - private _tools = $state(new SvelteSet()); - - constructor() { - try { - const stored = localStorage.getItem(ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY); - if (stored) { - for (const name of JSON.parse(stored) as string[]) { - if (typeof name === 'string') this._tools.add(name); - } - } - } catch (err) { - console.error( - `Failed to load permissions from localStorage ("${ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY}"):`, - err - ); - } - } - - get tools(): ReadonlySet { - return this._tools; - } - - hasTool(key: string): boolean { - return this._tools.has(key); - } - - allowTool(key: string): void { - this._tools.add(key); - this._persist(); - } - - allowTools(keys: string[]): void { - for (const key of keys) this._tools.add(key); - this._persist(); - } - - revokeTool(key: string): void { - this._tools.delete(key); - this._persist(); - } - - private _persist(): void { - try { - localStorage.setItem(ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY, JSON.stringify([...this._tools])); - } catch (err) { - console.error( - `Failed to persist to localStorage ("${ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY}"):`, - err - ); - } - } -} - -export const permissionsStore = new PermissionsStore(); diff --git a/tools/server/webui/src/lib/stores/persisted.svelte.ts b/tools/server/webui/src/lib/stores/persisted.svelte.ts deleted file mode 100644 index 1e07f80ed..000000000 --- a/tools/server/webui/src/lib/stores/persisted.svelte.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { browser } from '$app/environment'; - -type PersistedValue = { - get value(): T; - set value(newValue: T); -}; - -export function persisted(key: string, initialValue: T): PersistedValue { - let value = initialValue; - - if (browser) { - try { - const stored = localStorage.getItem(key); - - if (stored !== null) { - value = JSON.parse(stored) as T; - } - } catch (error) { - console.warn(`Failed to load ${key}:`, error); - } - } - - const persist = (next: T) => { - if (!browser) { - return; - } - - try { - if (next === null || next === undefined) { - localStorage.removeItem(key); - return; - } - - localStorage.setItem(key, JSON.stringify(next)); - } catch (error) { - console.warn(`Failed to persist ${key}:`, error); - } - }; - - return { - get value() { - return value; - }, - - set value(newValue: T) { - value = newValue; - persist(newValue); - } - }; -} diff --git a/tools/server/webui/src/lib/stores/server.svelte.ts b/tools/server/webui/src/lib/stores/server.svelte.ts deleted file mode 100644 index 48874bf1b..000000000 --- a/tools/server/webui/src/lib/stores/server.svelte.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { PropsService } from '$lib/services/props.service'; -import { ServerRole } from '$lib/enums'; - -/** - * serverStore - Server connection state, configuration, and role detection - * - * This store manages the server connection state and properties fetched from `/props`. - * It provides reactive state for server configuration and role detection. - * - * **Architecture & Relationships:** - * - **PropsService**: Stateless service for fetching `/props` data - * - **serverStore** (this class): Reactive store for server state - * - **modelsStore**: Independent store for model management (uses PropsService directly) - * - * **Key Features:** - * - **Server State**: Connection status, loading, error handling - * - **Role Detection**: MODEL (single model) vs ROUTER (multi-model) - * - **Default Params**: Server-wide generation defaults - */ -class ServerStore { - /** - * - * - * State - * - * - */ - - props = $state(null); - loading = $state(false); - error = $state(null); - role = $state(null); - private fetchPromise: Promise | null = null; - - /** - * - * - * Getters - * - * - */ - - get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null { - return this.props?.default_generation_settings?.params || null; - } - - get contextSize(): number | null { - const nCtx = this.props?.default_generation_settings?.n_ctx; - - return typeof nCtx === 'number' ? nCtx : null; - } - - get webuiSettings(): Record | undefined { - return this.props?.webui_settings; - } - - get isRouterMode(): boolean { - return this.role === ServerRole.ROUTER; - } - - get isModelMode(): boolean { - return this.role === ServerRole.MODEL; - } - - /** - * - * - * Data Handling - * - * - */ - - async fetch(): Promise { - if (this.fetchPromise) return this.fetchPromise; - - this.loading = true; - this.error = null; - - const fetchPromise = (async () => { - try { - const props = await PropsService.fetch(); - this.props = props; - this.error = null; - this.detectRole(props); - } catch (error) { - this.error = this.getErrorMessage(error); - console.error('Error fetching server properties:', error); - } finally { - this.loading = false; - this.fetchPromise = null; - } - })(); - - this.fetchPromise = fetchPromise; - await fetchPromise; - } - - private getErrorMessage(error: unknown): string { - if (error instanceof Error) { - const message = error.message || ''; - - if (error.name === 'TypeError' && message.includes('fetch')) { - return 'Server is not running or unreachable'; - } else if (message.includes('ECONNREFUSED')) { - return 'Connection refused - server may be offline'; - } else if (message.includes('ENOTFOUND')) { - return 'Server not found - check server address'; - } else if (message.includes('ETIMEDOUT')) { - return 'Request timed out'; - } else if (message.includes('503')) { - return 'Server temporarily unavailable'; - } else if (message.includes('500')) { - return 'Server error - check server logs'; - } else if (message.includes('404')) { - return 'Server endpoint not found'; - } else if (message.includes('403') || message.includes('401')) { - return 'Access denied'; - } - } - - return 'Failed to connect to server'; - } - - clear(): void { - this.props = null; - this.error = null; - this.loading = false; - this.role = null; - this.fetchPromise = null; - } - - /** - * - * - * Utilities - * - * - */ - - private detectRole(props: ApiLlamaCppServerProps): void { - const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL; - if (this.role !== newRole) { - this.role = newRole; - console.info(`Server running in ${newRole === ServerRole.ROUTER ? 'ROUTER' : 'MODEL'} mode`); - } - } -} - -export const serverStore = new ServerStore(); - -export const serverProps = () => serverStore.props; -export const serverLoading = () => serverStore.loading; -export const serverError = () => serverStore.error; -export const serverRole = () => serverStore.role; -export const defaultParams = () => serverStore.defaultParams; -export const contextSize = () => serverStore.contextSize; -export const isRouterMode = () => serverStore.isRouterMode; -export const isModelMode = () => serverStore.isModelMode; diff --git a/tools/server/webui/src/lib/stores/settings-referrer.svelte.ts b/tools/server/webui/src/lib/stores/settings-referrer.svelte.ts deleted file mode 100644 index 297a0d6a4..000000000 --- a/tools/server/webui/src/lib/stores/settings-referrer.svelte.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants'; - -let _url = $state(SETTINGS_FALLBACK_EXIT_ROUTE); - -export const settingsReferrer = { - get url() { - return _url; - }, - set url(value: string) { - _url = value; - } -}; diff --git a/tools/server/webui/src/lib/stores/settings.svelte.ts b/tools/server/webui/src/lib/stores/settings.svelte.ts deleted file mode 100644 index da5e4024e..000000000 --- a/tools/server/webui/src/lib/stores/settings.svelte.ts +++ /dev/null @@ -1,548 +0,0 @@ -/** - * settingsStore - Application configuration and theme management - * - * This store manages all application settings including AI model parameters, UI preferences, - * and theme configuration. It provides persistent storage through localStorage with reactive - * state management using Svelte 5 runes. - * - * **Architecture & Relationships:** - * - **settingsStore** (this class): Configuration state management - * - Manages AI model parameters (temperature, max tokens, etc.) - * - Handles theme switching and persistence - * - Provides localStorage synchronization - * - Offers reactive configuration access - * - * - **ChatService**: Reads model parameters for API requests - * - **UI Components**: Subscribe to theme and configuration changes - * - * **Key Features:** - * - **Model Parameters**: Temperature, max tokens, top-p, top-k, repeat penalty - * - **Theme Management**: Auto, light, dark theme switching - * - **Persistence**: Automatic localStorage synchronization - * - **Reactive State**: Svelte 5 runes for automatic UI updates - * - **Default Handling**: Graceful fallback to defaults for missing settings - * - **Batch Updates**: Efficient multi-setting updates - * - **Reset Functionality**: Restore defaults for individual or all settings - * - * **Configuration Categories:** - * - Generation parameters (temperature, tokens, sampling) - * - UI preferences (theme, display options) - * - System settings (model selection, prompts) - * - Advanced options (seed, penalties, context handling) - */ - -import { browser } from '$app/environment'; -import { ColorMode } from '$lib/enums'; -import type { SettingsExportType } from '$lib/types'; -import { setMode } from 'mode-watcher'; -import { - CONFIG_LOCALSTORAGE_KEY, - SETTING_CONFIG_DEFAULT, - SETTINGS_KEYS, - USER_OVERRIDES_LOCALSTORAGE_KEY -} from '$lib/constants'; -import { IsMobile } from '$lib/hooks/is-mobile.svelte'; -import { ParameterSyncService } from '$lib/services/parameter-sync.service'; -import { serverStore } from '$lib/stores/server.svelte'; -import { - configToParameterRecord, - normalizeFloatingPoint, - getConfigValue, - setConfigValue -} from '$lib/utils'; - -class SettingsStore { - /** - * - * - * State - * - * - */ - - config = $state({ ...SETTING_CONFIG_DEFAULT }); - isInitialized = $state(false); - userOverrides = $state>(new Set()); - - /** - * - * - * Utilities (private helpers) - * - * - */ - - /** - * Helper method to get server defaults with null safety - * Centralizes the pattern of getting and extracting server defaults - */ - private getServerDefaults(): Record { - const serverParams = serverStore.defaultParams; - const webuiSettings = serverStore.webuiSettings; - return ParameterSyncService.extractServerDefaults(serverParams, webuiSettings); - } - - constructor() { - if (browser) { - this.initialize(); - } - } - - /** - * - * - * Lifecycle - * - * - */ - - /** - * Initialize the settings store by loading from localStorage - */ - initialize() { - try { - this.loadConfig(); - this.migrateLegacyTheme(); - // Apply the persisted theme from config on initial load - setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode); - this.isInitialized = true; - } catch (error) { - console.error('Failed to initialize settings store:', error); - } - } - - /** - * Load configuration from localStorage - * Returns default values for missing keys to prevent breaking changes - */ - private loadConfig() { - if (!browser) return; - - try { - const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY); - const savedVal = JSON.parse(storedConfigRaw || '{}'); - - // Merge with defaults to prevent breaking changes - this.config = { - ...SETTING_CONFIG_DEFAULT, - ...savedVal - }; - - // Default sendOnEnter to false on mobile when the user has no saved preference - if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) { - if (new IsMobile().current) { - this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false; - } - } - - // Load user overrides - const savedOverrides = JSON.parse( - localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]' - ); - this.userOverrides = new Set(savedOverrides); - } catch (error) { - console.warn('Failed to parse config from localStorage, using defaults:', error); - this.config = { ...SETTING_CONFIG_DEFAULT }; - this.userOverrides = new Set(); - } - } - - /** - * Migrate the legacy un-namespaced "theme" localStorage key into config. - * Previously theme was stored separately in localStorage("theme") — now it lives - * inside the config object alongside all other settings. - * After migration the legacy key is removed. - */ - private migrateLegacyTheme() { - if (!browser) return; - - const legacyTheme = localStorage.getItem('theme'); - if (legacyTheme) { - this.config[SETTINGS_KEYS.THEME] = legacyTheme; - localStorage.removeItem('theme'); - this.saveConfig(); - setMode(legacyTheme as ColorMode); - } - } - /** - * - * - * Config Updates - * - * - */ - - /** - * Update a specific configuration setting - * @param key - The configuration key to update - * @param value - The new value for the configuration key - */ - updateConfig(key: K, value: SettingsConfigType[K]): void { - this.config[key] = value; - - if (ParameterSyncService.canSyncParameter(key as string)) { - const propsDefaults = this.getServerDefaults(); - const propsDefault = propsDefaults[key as string]; - - if (propsDefault !== undefined) { - const normalizedValue = normalizeFloatingPoint(value); - const normalizedDefault = normalizeFloatingPoint(propsDefault); - - if (normalizedValue === normalizedDefault) { - this.userOverrides.delete(key as string); - } else { - this.userOverrides.add(key as string); - } - } - } - - this.saveConfig(); - } - - /** - * Update multiple configuration settings at once - * @param updates - Object containing the configuration updates - */ - updateMultipleConfig(updates: Partial) { - Object.assign(this.config, updates); - - const propsDefaults = this.getServerDefaults(); - - for (const [key, value] of Object.entries(updates)) { - if (ParameterSyncService.canSyncParameter(key)) { - const propsDefault = propsDefaults[key]; - - if (propsDefault !== undefined) { - const normalizedValue = normalizeFloatingPoint(value); - const normalizedDefault = normalizeFloatingPoint(propsDefault); - - if (normalizedValue === normalizedDefault) { - this.userOverrides.delete(key); - } else { - this.userOverrides.add(key); - } - } - } - } - - this.saveConfig(); - } - - /** - * Save the current configuration to localStorage - */ - private saveConfig() { - if (!browser) return; - - try { - localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(this.config)); - - localStorage.setItem( - USER_OVERRIDES_LOCALSTORAGE_KEY, - JSON.stringify(Array.from(this.userOverrides)) - ); - } catch (error) { - console.error('Failed to save config to localStorage:', error); - } - } - - /** - * Update the theme setting. - * @param newTheme - The new theme value - */ - updateTheme(newTheme: string) { - this.updateConfig(SETTINGS_KEYS.THEME, newTheme); - - setMode(newTheme as ColorMode); - } - - /** - * - * - * Reset - * - * - */ - - /** - * Reset configuration to defaults - */ - resetConfig() { - this.config = { ...SETTING_CONFIG_DEFAULT }; - - this.saveConfig(); - } - - /** - * Reset theme to default value. - * Theme is now stored inside the config object. - */ - resetTheme() { - this.updateConfig(SETTINGS_KEYS.THEME, SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME]); - - setMode(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME] as ColorMode); - } - - /** - * Reset all settings to defaults. - */ - resetAll() { - this.resetConfig(); - - this.resetTheme(); - } - - /** - * Reset a parameter to server default (or webui default if no server default) - */ - resetParameterToServerDefault(key: string): void { - const serverDefaults = this.getServerDefaults(); - const webuiSettings = serverStore.webuiSettings; - - if (webuiSettings && key in webuiSettings) { - // UI setting from admin config: write actual value - setConfigValue(this.config, key, webuiSettings[key]); - } else if (serverDefaults[key] !== undefined) { - // sampling param known by server: clear it, let server decide - setConfigValue(this.config, key, ''); - } else if (key in SETTING_CONFIG_DEFAULT) { - setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key)); - } - - this.userOverrides.delete(key); - this.saveConfig(); - } - - /** - * - * - * Server Sync - * - * - */ - - /** - * Initialize settings with props defaults when server properties are first loaded - * This sets up the default values from /props endpoint - */ - syncWithServerDefaults(): void { - const propsDefaults = this.getServerDefaults(); - if (Object.keys(propsDefaults).length === 0) return; - - const webuiSettings = serverStore.webuiSettings; - const webuiSettingsKeys = new Set(webuiSettings ? Object.keys(webuiSettings) : []); - - for (const [key, propsValue] of Object.entries(propsDefaults)) { - const currentValue = getConfigValue(this.config, key); - - const normalizedCurrent = normalizeFloatingPoint(currentValue); - const normalizedDefault = normalizeFloatingPoint(propsValue); - - // if user value matches server, it's not a real override - if (normalizedCurrent === normalizedDefault) { - this.userOverrides.delete(key); - - if ( - !webuiSettingsKeys.has(key) && - getConfigValue(SETTING_CONFIG_DEFAULT, key) === undefined - ) { - setConfigValue(this.config, key, undefined); - } - } - } - - // webui settings need actual values in config (no placeholder mechanism), - // so write them for non-overridden keys - if (webuiSettings) { - for (const [key, value] of Object.entries(webuiSettings)) { - if (!this.userOverrides.has(key) && value !== undefined) { - setConfigValue(this.config, key, value); - - // theme lives in mode-watcher, not just in config -> propagate - if (key === SETTINGS_KEYS.THEME) { - setMode(value as ColorMode); - } - } - } - } - - this.saveConfig(); - console.log('User overrides after sync:', Array.from(this.userOverrides)); - } - - /** - * Reset all parameters to their default values (from props) - * This is used by the "Reset to Default" functionality - * Prioritizes server defaults from /props, falls back to webui defaults - */ - forceSyncWithServerDefaults(): void { - const propsDefaults = this.getServerDefaults(); - const webuiSettings = serverStore.webuiSettings; - - for (const key of ParameterSyncService.getSyncableParameterKeys()) { - if (webuiSettings && key in webuiSettings) { - // UI setting from admin config: write actual value - setConfigValue(this.config, key, webuiSettings[key]); - } else if (propsDefaults[key] !== undefined) { - // sampling param: clear it, let server decide - setConfigValue(this.config, key, ''); - } else if (key in SETTING_CONFIG_DEFAULT) { - setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key)); - } - - this.userOverrides.delete(key); - } - - this.saveConfig(); - } - - /** - * - * - * Utilities - * - * - */ - - /** - * Get a specific configuration value - * @param key - The configuration key to get - * @returns The configuration value - */ - getConfig(key: K): SettingsConfigType[K] { - return this.config[key]; - } - - /** - * Get the entire configuration object - * @returns The complete configuration object - */ - getAllConfig(): SettingsConfigType { - return { ...this.config }; - } - - canSyncParameter(key: string): boolean { - return ParameterSyncService.canSyncParameter(key); - } - - /** - * Get parameter information including source for a specific parameter - */ - getParameterInfo(key: string) { - const propsDefaults = this.getServerDefaults(); - const currentValue = getConfigValue(this.config, key); - - return ParameterSyncService.getParameterInfo( - key, - currentValue ?? '', - propsDefaults, - this.userOverrides - ); - } - - /** - * Get diff between current settings and server defaults - */ - getParameterDiff() { - const serverDefaults = this.getServerDefaults(); - if (Object.keys(serverDefaults).length === 0) return {}; - - const configAsRecord = configToParameterRecord( - this.config, - ParameterSyncService.getSyncableParameterKeys() - ); - - return ParameterSyncService.createParameterDiff(configAsRecord, serverDefaults); - } - - /** - * Clear all user overrides (for debugging) - */ - clearAllUserOverrides(): void { - this.userOverrides.clear(); - this.saveConfig(); - console.log('Cleared all user overrides'); - } - - /** - * - * - * Import / Export - * - * - */ - - /** - * Export all settings as a versioned JSON-compatible object. - * The export captures the full config (excluding sensitive values like API key) - * and user overrides. Sensitive fields are filtered out for security by default. - * @param includeSensitiveData - If true, include sensitive fields (apiKey, MCP server headers) in export - */ - exportSettings(includeSensitiveData: boolean = false): SettingsExportType { - // Build config excluding sensitive data unless user opts in - const configToExport: Record = - includeSensitiveData - ? { ...this.config } - : Object.fromEntries(Object.entries(this.config).filter(([key]) => key !== 'apiKey')); - - // Handle MCP servers: exclude custom headers unless user opts in - if ('mcpServers' in configToExport && !includeSensitiveData) { - try { - const mcpServers = JSON.parse(configToExport.mcpServers as string) as Array< - Record - >; - const safeServers = mcpServers.map((server) => { - delete server.headers; - return server; - }); - configToExport.mcpServers = JSON.stringify(safeServers); - } catch { - // If parsing fails, just exclude the entire mcpServers field - delete (configToExport as Record).mcpServers; - } - } - - return { - version: 1, - timestamp: Date.now(), - config: configToExport, - userOverrides: Array.from(this.userOverrides) - }; - } - - /** - * Import settings from a previously exported object. - * Restores config (including theme) and user overrides. - * @param data - The exported settings object - */ - importSettings(data: SettingsExportType): void { - if (!browser) return; - - if (!data || !data.config) { - throw new Error('Invalid settings data: missing config'); - } - - // Restore config (theme is included in config) - this.config = { - ...SETTING_CONFIG_DEFAULT, - ...data.config - }; - - // Restore user overrides (derived state — may be stale if server defaults differ) - this.userOverrides = new Set(data.userOverrides ?? []); - - // Persist to localStorage - this.saveConfig(); - - // Apply theme for immediate visual feedback - setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode); - - console.log('Settings imported successfully'); - } -} - -export const settingsStore = new SettingsStore(); - -export const config = () => settingsStore.config; -export const theme = () => settingsStore.config[SETTINGS_KEYS.THEME]; -export const isInitialized = () => settingsStore.isInitialized; diff --git a/tools/server/webui/src/lib/stores/tools.svelte.ts b/tools/server/webui/src/lib/stores/tools.svelte.ts deleted file mode 100644 index d55736d02..000000000 --- a/tools/server/webui/src/lib/stores/tools.svelte.ts +++ /dev/null @@ -1,422 +0,0 @@ -import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types'; -import { ToolsService } from '$lib/services/tools.service'; -import { mcpStore } from '$lib/stores/mcp.svelte'; -import { HealthCheckStatus, JsonSchemaType, ToolCallType, ToolSource } from '$lib/enums'; -import { config } from '$lib/stores/settings.svelte'; -import { - DISABLED_TOOLS_LOCALSTORAGE_KEY, - TOOL_GROUP_LABELS, - TOOL_SERVER_LABELS -} from '$lib/constants'; -import { SvelteSet } from 'svelte/reactivity'; - -class ToolsStore { - private _builtinTools = $state([]); - private _loading = $state(false); - private _error = $state(null); - private _disabledTools = $state(new SvelteSet()); - private _toolsEndpointUnreachable = $state(false); - - constructor() { - try { - const stored = localStorage.getItem(DISABLED_TOOLS_LOCALSTORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored); - if (Array.isArray(parsed)) { - for (const name of parsed) { - if (typeof name === 'string') this._disabledTools.add(name); - } - } - } - } catch (err) { - console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err); - } - - // Initialize builtin tools on startup - this.fetchBuiltinTools(); - } - - private persistDisabledTools(): void { - try { - localStorage.setItem( - DISABLED_TOOLS_LOCALSTORAGE_KEY, - JSON.stringify([...this._disabledTools]) - ); - } catch { - // ignore storage errors - } - } - - get builtinTools(): OpenAIToolDefinition[] { - return this._builtinTools; - } - - get mcpTools(): OpenAIToolDefinition[] { - return mcpStore.getToolDefinitionsForLLM(); - } - - get customTools(): OpenAIToolDefinition[] { - const raw = config().custom; - if (!raw || typeof raw !== 'string') return []; - - try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - - return parsed.filter( - (t: unknown): t is OpenAIToolDefinition => - typeof t === 'object' && - t !== null && - 'type' in t && - (t as OpenAIToolDefinition).type === 'function' && - 'function' in t && - typeof (t as OpenAIToolDefinition).function?.name === 'string' - ); - } catch { - return []; - } - } - - /** Flat list of all tool entries with source metadata */ - get allTools(): ToolEntry[] { - const entries: ToolEntry[] = []; - - for (const def of this._builtinTools) { - entries.push({ source: ToolSource.BUILTIN, definition: def }); - } - - // Use live connections when available (full schema), fall back to health check data - const connections = mcpStore.getConnections(); - if (connections.size > 0) { - for (const [serverId, connection] of connections) { - const serverName = mcpStore.getServerDisplayName(serverId); - for (const tool of connection.tools) { - const rawSchema = (tool.inputSchema as Record) ?? { - type: JsonSchemaType.OBJECT, - properties: {}, - required: [] - }; - entries.push({ - source: ToolSource.MCP, - serverName, - serverId, - definition: { - type: ToolCallType.FUNCTION, - function: { - name: tool.name, - description: tool.description, - parameters: rawSchema - } - } - }); - } - } - } else { - for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) { - for (const tool of tools) { - entries.push({ - source: ToolSource.MCP, - serverName, - serverId, - definition: { - type: ToolCallType.FUNCTION, - function: { - name: tool.name, - description: tool.description, - parameters: { type: JsonSchemaType.OBJECT, properties: {}, required: [] } - } - } - }); - } - } - } - - for (const def of this.customTools) { - entries.push({ source: ToolSource.CUSTOM, definition: def }); - } - - return entries; - } - - /** Tools grouped by category for tree display */ - get toolGroups(): ToolGroup[] { - const groups: ToolGroup[] = []; - - if (this._builtinTools.length > 0) { - groups.push({ - source: ToolSource.BUILTIN, - label: TOOL_GROUP_LABELS[ToolSource.BUILTIN], - tools: this._builtinTools - }); - } - - // Use live connections when available, fall back to health check data - const connections = mcpStore.getConnections(); - if (connections.size > 0) { - for (const [serverId, connection] of connections) { - if (connection.tools.length === 0) continue; - const label = mcpStore.getServerDisplayName(serverId); - const tools: OpenAIToolDefinition[] = connection.tools.map((tool) => { - const rawSchema = (tool.inputSchema as Record) ?? { - type: JsonSchemaType.OBJECT, - properties: {}, - required: [] - }; - return { - type: ToolCallType.FUNCTION, - function: { - name: tool.name, - description: tool.description, - parameters: rawSchema - } - }; - }); - groups.push({ source: ToolSource.MCP, label, serverId, tools }); - } - } else { - for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) { - if (tools.length === 0) continue; - const defs: OpenAIToolDefinition[] = tools.map((tool) => ({ - type: ToolCallType.FUNCTION, - function: { - name: tool.name, - description: tool.description, - parameters: { type: JsonSchemaType.OBJECT, properties: {}, required: [] } - } - })); - groups.push({ source: ToolSource.MCP, label: serverName, serverId, tools: defs }); - } - } - - const custom = this.customTools; - if (custom.length > 0) { - groups.push({ - source: ToolSource.CUSTOM, - label: TOOL_GROUP_LABELS[ToolSource.CUSTOM], - tools: custom - }); - } - - return groups; - } - - /** Only enabled tool definitions (for sending to the API) */ - get enabledToolDefinitions(): OpenAIToolDefinition[] { - return this.allTools - .filter((t) => !this._disabledTools.has(t.definition.function.name)) - .map((t) => t.definition); - } - - /** - * Returns enabled tool definitions for sending to the LLM. - * MCP tools use properly normalized schemas from mcpStore. - * Filters out tools disabled via the UI checkboxes. - */ - getEnabledToolsForLLM(): OpenAIToolDefinition[] { - const disabled = this._disabledTools; - const result: OpenAIToolDefinition[] = []; - - for (const tool of this._builtinTools) { - if (!disabled.has(tool.function.name)) { - result.push(tool); - } - } - - // MCP tools with properly normalized schemas - for (const tool of mcpStore.getToolDefinitionsForLLM()) { - if (!disabled.has(tool.function.name)) { - result.push(tool); - } - } - - for (const tool of this.customTools) { - if (!disabled.has(tool.function.name)) { - result.push(tool); - } - } - - return result; - } - - get allToolDefinitions(): OpenAIToolDefinition[] { - return this.allTools.map((t) => t.definition); - } - - get loading(): boolean { - return this._loading; - } - - get error(): string | null { - return this._error; - } - - get isToolsEndpointUnreachable(): boolean { - return this._toolsEndpointUnreachable; - } - - get disabledTools(): SvelteSet { - return this._disabledTools; - } - - isToolEnabled(toolName: string): boolean { - return !this._disabledTools.has(toolName); - } - - toggleTool(toolName: string): void { - if (this._disabledTools.has(toolName)) { - this._disabledTools.delete(toolName); - } else { - this._disabledTools.add(toolName); - } - this.persistDisabledTools(); - } - - setToolEnabled(toolName: string, enabled: boolean): void { - if (enabled) { - this._disabledTools.delete(toolName); - } else { - this._disabledTools.add(toolName); - } - } - - /** - * Enable all tools belonging to a specific MCP server. - * Called when a server is enabled for a conversation. - */ - enableAllToolsForServer(serverId: string): void { - const connection = mcpStore.getConnections().get(serverId); - if (!connection) return; - for (const tool of connection.tools) { - this._disabledTools.delete(tool.name); - } - this.persistDisabledTools(); - } - - toggleGroup(group: ToolGroup): void { - const allEnabled = group.tools.every((t) => this.isToolEnabled(t.function.name)); - for (const tool of group.tools) { - this.setToolEnabled(tool.function.name, !allEnabled); - } - this.persistDisabledTools(); - } - - isGroupFullyEnabled(group: ToolGroup): boolean { - return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.function.name)); - } - - isGroupPartiallyEnabled(group: ToolGroup): boolean { - const enabledCount = group.tools.filter((t) => this.isToolEnabled(t.function.name)).length; - return enabledCount > 0 && enabledCount < group.tools.length; - } - - /** - * Get MCP tools from health check data (reactive). - * Used when live connections aren't established yet. - */ - private getMcpToolsFromHealthChecks(): { - serverId: string; - serverName: string; - tools: { name: string; description?: string }[]; - }[] { - const result: ReturnType = []; - for (const server of mcpStore.getServersSorted().filter((s) => s.enabled)) { - const health = mcpStore.getHealthCheckState(server.id); - if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) { - result.push({ - serverId: server.id, - serverName: mcpStore.getServerLabel(server), - tools: health.tools - }); - } - } - return result; - } - - /** Determine the source of a tool by its name. */ - getToolSource(toolName: string): ToolSource | null { - if (this._builtinTools.some((t) => t.function.name === toolName)) { - return ToolSource.BUILTIN; - } - for (const entry of this.allTools) { - if (entry.definition.function.name === toolName) { - return entry.source; - } - } - return null; - } - - /** Get the display label for the server that owns a given tool. */ - getToolServerLabel(toolName: string): string { - for (const entry of this.allTools) { - if (entry.definition.function.name === toolName) { - if (entry.serverName) { - return mcpStore.getServerDisplayName(entry.serverName); - } - if (entry.source === ToolSource.BUILTIN) { - return TOOL_SERVER_LABELS[ToolSource.BUILTIN]; - } - if (entry.source === ToolSource.CUSTOM) { - return TOOL_SERVER_LABELS[ToolSource.CUSTOM]; - } - } - } - return ''; - } - - /** Build a permission key with category prefix, e.g. "mcp-:tool_name" */ - getPermissionKey(toolName: string): string | null { - for (const entry of this.allTools) { - if (entry.definition.function.name === toolName) { - switch (entry.source) { - case ToolSource.BUILTIN: - return `builtin:${toolName}`; - case ToolSource.CUSTOM: - return `custom:${toolName}`; - case ToolSource.MCP: - if (entry.serverId) { - return `mcp-${entry.serverId}:${toolName}`; - } - return `mcp:${toolName}`; - default: - return null; - } - } - } - return null; - } - - /** Check if there are any enabled tools available (builtin, MCP, or custom). */ - get hasEnabledTools(): boolean { - return this.getEnabledToolsForLLM().length > 0; - } - - async fetchBuiltinTools(): Promise { - if (this._loading) return; - - this._loading = true; - this._error = null; - this._toolsEndpointUnreachable = false; - - try { - const toolInfos = await ToolsService.list(); - this._builtinTools = toolInfos.map((info) => info.definition); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - this._error = errorMessage; - // 404 from /tools means the server was started without --tools - if (errorMessage.includes('404') || errorMessage.toLowerCase().includes('not found')) { - this._toolsEndpointUnreachable = true; - } - console.error('[ToolsStore] Failed to fetch built-in tools:', err); - } finally { - this._loading = false; - } - } -} - -export const toolsStore = new ToolsStore(); - -export const allTools = () => toolsStore.allTools; -export const allToolDefinitions = () => toolsStore.allToolDefinitions; -export const enabledToolDefinitions = () => toolsStore.enabledToolDefinitions; -export const toolGroups = () => toolsStore.toolGroups; diff --git a/tools/server/webui/src/lib/types/agentic.d.ts b/tools/server/webui/src/lib/types/agentic.d.ts deleted file mode 100644 index b94998384..000000000 --- a/tools/server/webui/src/lib/types/agentic.d.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { MessageRole } from '$lib/enums'; -import { ToolCallType } from '$lib/enums'; -import type { - ApiChatCompletionRequest, - ApiChatCompletionToolCall, - ApiChatMessageContentPart, - ApiChatMessageData -} from './api'; -import type { ChatMessageTimings, ChatMessagePromptProgress } from './chat'; -import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from './database'; - -/** - * Agentic orchestration configuration. - */ -export interface AgenticConfig { - enabled: boolean; - maxTurns: number; - maxToolPreviewLines: number; -} - -/** - * Tool call payload for agentic messages. - */ -export type AgenticToolCallPayload = { - id: string; - type: ToolCallType.FUNCTION; - function: { - name: string; - arguments: string; - }; -}; - -/** - * Agentic message types for different roles. - */ -export type AgenticMessage = - | { - role: MessageRole.SYSTEM | MessageRole.USER; - content: string | ApiChatMessageContentPart[]; - } - | { - role: MessageRole.ASSISTANT; - content?: string | ApiChatMessageContentPart[]; - reasoning_content?: string; - tool_calls?: AgenticToolCallPayload[]; - } - | { - role: MessageRole.TOOL; - tool_call_id: string; - content: string | ApiChatMessageContentPart[]; - }; - -export type AgenticAssistantMessage = Extract; -export type AgenticToolCallList = NonNullable; - -export type AgenticChatCompletionRequest = Omit & { - messages: AgenticMessage[]; - stream: true; - tools?: ApiChatCompletionRequest['tools']; -}; - -/** - * Per-conversation agentic session state. - * Enables parallel agentic flows across multiple chats. - */ -export interface AgenticSession { - isRunning: boolean; - currentTurn: number; - totalToolCalls: number; - lastError: Error | null; - streamingToolCall: { name: string; arguments: string } | null; - pendingPermissionRequest: { toolName: string; serverLabel: string } | null; -} - -/** - * Callbacks for agentic flow execution. - * - * The agentic loop creates separate DB messages for each turn: - * - assistant messages (one per LLM turn, with tool_calls if any) - * - tool result messages (one per tool call execution) - * - * The first assistant message is created by the caller before starting the flow. - * Subsequent messages are created via createToolResultMessage / createAssistantMessage. - */ -export interface AgenticFlowCallbacks { - /** Content chunk for the current assistant message */ - onChunk?: (chunk: string) => void; - /** Reasoning content chunk for the current assistant message */ - onReasoningChunk?: (chunk: string) => void; - /** Tool calls being streamed (partial, accumulating) for the current turn */ - onToolCallsStreaming?: (toolCalls: ApiChatCompletionToolCall[]) => void; - /** Attachments extracted from tool results */ - onAttachments?: (messageId: string, extras: DatabaseMessageExtra[]) => void; - /** Model name detected from response */ - onModel?: (model: string) => void; - /** Current assistant turn's streaming is complete - save to DB */ - onAssistantTurnComplete?: ( - content: string, - reasoningContent: string | undefined, - timings: ChatMessageTimings | undefined, - toolCalls: ApiChatCompletionToolCall[] | undefined - ) => Promise; - /** Create a tool result message in the DB tree */ - createToolResultMessage?: ( - toolCallId: string, - content: string, - extras?: DatabaseMessageExtra[] - ) => Promise; - /** Create a new assistant message for the next agentic turn */ - createAssistantMessage?: () => Promise; - /** Entire agentic flow is complete */ - onFlowComplete?: (timings?: ChatMessageTimings) => void; - /** Error during flow */ - onError?: (error: Error) => void; - /** Timing updates during streaming */ - onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void; - /** An agentic turn (LLM + tool execution) completed - intermediate timing update */ - onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void; -} - -/** - * Options for agentic flow execution - */ -export interface AgenticFlowOptions { - stream?: boolean; - model?: string; - temperature?: number; - max_tokens?: number; - [key: string]: unknown; -} - -/** - * Parameters for starting an agentic flow - */ -export interface AgenticFlowParams { - conversationId: string; - messages: (ApiChatMessageData | (DatabaseMessage & { extra?: DatabaseMessageExtra[] }))[]; - options?: AgenticFlowOptions; - callbacks: AgenticFlowCallbacks; - signal?: AbortSignal; - perChatOverrides?: McpServerOverride[]; -} - -/** - * Result of an agentic flow execution - */ -export interface AgenticFlowResult { - handled: boolean; - error?: Error; -} - -/** - * A user message to be injected into the agentic loop between turns. - */ -export interface SteeringMessage { - content: string; - extras?: DatabaseMessageExtra[]; -} diff --git a/tools/server/webui/src/lib/types/api.d.ts b/tools/server/webui/src/lib/types/api.d.ts deleted file mode 100644 index 63a464cf1..000000000 --- a/tools/server/webui/src/lib/types/api.d.ts +++ /dev/null @@ -1,457 +0,0 @@ -import type { ContentPartType, ServerModelStatus, ServerRole } from '$lib/enums'; -import type { ChatMessagePromptProgress, ChatRole } from './chat'; - -export interface ApiChatCompletionToolFunction { - name: string; - description?: string; - parameters: Record; -} - -export interface ApiChatCompletionTool { - type: 'function'; - function: ApiChatCompletionToolFunction; -} - -export interface ApiChatMessageContentPart { - type: ContentPartType; - text?: string; - image_url?: { - url: string; - }; - input_audio?: { - data: string; - format: 'wav' | 'mp3'; - }; -} - -export interface ApiContextSizeError { - code: number; - message: string; - type: 'exceed_context_size_error'; - n_prompt_tokens: number; - n_ctx: number; -} - -export interface ApiErrorResponse { - error: - | ApiContextSizeError - | { - code: number; - message: string; - type?: string; - }; -} - -export interface ApiChatMessageData { - role: ChatRole; - content: string | ApiChatMessageContentPart[]; - reasoning_content?: string; - tool_calls?: ApiChatCompletionToolCall[]; - tool_call_id?: string; - timestamp?: number; -} - -/** - * Model status object from /models endpoint - */ -export interface ApiModelStatus { - /** Status value: loaded, unloaded, loading, sleeping, failed */ - value: ServerModelStatus; - /** Command line arguments used when loading (only for loaded models) */ - args?: string[]; -} - -/** - * Model entry from /models endpoint (ROUTER mode) - * Based on actual API response structure - */ -export interface ApiModelDataEntry { - /** Model identifier (e.g., "ggml-org/Qwen2.5-Omni-7B-GGUF:latest") */ - id: string; - /** Model name (optional, usually same as id - not always returned by API) */ - name?: string; - /** Object type, always "model" */ - object: string; - /** Owner, usually "llamacpp" */ - owned_by: string; - /** Creation timestamp */ - created: number; - /** Whether model files are in HuggingFace cache */ - in_cache: boolean; - /** Path to model manifest file */ - path: string; - /** Current status of the model */ - status: ApiModelStatus; - /** Alternative names that resolve to this model */ - aliases?: string[]; - /** Informational tags for this model */ - tags?: string[]; - /** Legacy meta field (may be present in older responses) */ - meta?: Record | null; -} - -export interface ApiModelDetails { - name: string; - model: string; - modified_at?: string; - size?: string | number; - digest?: string; - type?: string; - description?: string; - tags?: string[]; - capabilities?: string[]; - parameters?: string; - details?: { - parent_model?: string; - format?: string; - family?: string; - families?: string[]; - parameter_size?: string; - quantization_level?: string; - }; -} - -export interface ApiModelListResponse { - object: string; - data: ApiModelDataEntry[]; - models?: ApiModelDetails[]; -} - -export interface ApiLlamaCppServerProps { - default_generation_settings: { - id: number; - id_task: number; - n_ctx: number; - speculative: boolean; - is_processing: boolean; - params: { - n_predict: number; - seed: number; - temperature: number; - dynatemp_range: number; - dynatemp_exponent: number; - top_k: number; - top_p: number; - min_p: number; - top_n_sigma: number; - xtc_probability: number; - xtc_threshold: number; - typ_p: number; - repeat_last_n: number; - repeat_penalty: number; - presence_penalty: number; - frequency_penalty: number; - dry_multiplier: number; - dry_base: number; - dry_allowed_length: number; - dry_penalty_last_n: number; - dry_sequence_breakers: string[]; - mirostat: number; - mirostat_tau: number; - mirostat_eta: number; - stop: string[]; - max_tokens: number; - n_keep: number; - n_discard: number; - ignore_eos: boolean; - stream: boolean; - logit_bias: Array<[number, number]>; - n_probs: number; - min_keep: number; - grammar: string; - grammar_lazy: boolean; - grammar_triggers: string[]; - preserved_tokens: number[]; - chat_format: string; - reasoning_format: string; - reasoning_in_content: boolean; - generation_prompt: string; - samplers: string[]; - backend_sampling: boolean; - 'speculative.n_max': number; - 'speculative.n_min': number; - 'speculative.p_min': number; - timings_per_token: boolean; - post_sampling_probs: boolean; - lora: Array<{ name: string; scale: number }>; - }; - prompt: string; - next_token: { - has_next_token: boolean; - has_new_line: boolean; - n_remain: number; - n_decoded: number; - stopping_word: string; - }; - }; - total_slots: number; - model_path: string; - role: ServerRole; - modalities: { - vision: boolean; - audio: boolean; - }; - chat_template: string; - bos_token: string; - eos_token: string; - build_info: string; - webui_settings?: Record; -} - -export interface ApiChatCompletionRequest { - messages: Array<{ - role: ChatRole; - content: string | ApiChatMessageContentPart[]; - reasoning_content?: string; - tool_calls?: ApiChatCompletionToolCall[]; - tool_call_id?: string; - }>; - stream?: boolean; - model?: string; - return_progress?: boolean; - tools?: ApiChatCompletionTool[]; - // Reasoning parameters - reasoning_format?: string; - // Generation parameters - temperature?: number; - max_tokens?: number; - // Sampling parameters - dynatemp_range?: number; - dynatemp_exponent?: number; - top_k?: number; - top_p?: number; - min_p?: number; - xtc_probability?: number; - xtc_threshold?: number; - typ_p?: number; - // Penalty parameters - repeat_last_n?: number; - repeat_penalty?: number; - presence_penalty?: number; - frequency_penalty?: number; - dry_multiplier?: number; - dry_base?: number; - dry_allowed_length?: number; - dry_penalty_last_n?: number; - // Sampler configuration - samplers?: string[]; - backend_sampling?: boolean; - // Custom parameters (JSON string) - custom?: Record; - timings_per_token?: boolean; - // Continuation control (vLLM compat) - add_generation_prompt?: boolean; - continue_final_message?: boolean; -} - -export interface ApiChatCompletionToolCallFunctionDelta { - name?: string; - arguments?: string; -} - -export interface ApiChatCompletionToolCallDelta { - index?: number; - id?: string; - type?: string; - function?: ApiChatCompletionToolCallFunctionDelta; -} - -export interface ApiChatCompletionToolCall extends ApiChatCompletionToolCallDelta { - function?: ApiChatCompletionToolCallFunctionDelta & { arguments?: string }; -} - -export interface ApiChatCompletionStreamChunk { - object?: string; - model?: string; - choices: Array<{ - model?: string; - metadata?: { model?: string }; - delta: { - content?: string; - reasoning_content?: string; - model?: string; - tool_calls?: ApiChatCompletionToolCallDelta[]; - }; - finish_reason?: string | null; - }>; - timings?: { - prompt_n?: number; - prompt_ms?: number; - predicted_n?: number; - predicted_ms?: number; - cache_n?: number; - }; - prompt_progress?: ChatMessagePromptProgress; -} - -export interface ApiChatCompletionResponse { - model?: string; - choices: Array<{ - model?: string; - metadata?: { model?: string }; - message: { - content: string; - reasoning_content?: string; - model?: string; - tool_calls?: ApiChatCompletionToolCall[]; - }; - finish_reason?: string | null; - }>; -} - -export interface ApiSlotData { - id: number; - id_task: number; - n_ctx: number; - speculative: boolean; - is_processing: boolean; - params: { - n_predict: number; - seed: number; - temperature: number; - dynatemp_range: number; - dynatemp_exponent: number; - top_k: number; - top_p: number; - min_p: number; - top_n_sigma: number; - xtc_probability: number; - xtc_threshold: number; - typical_p: number; - repeat_last_n: number; - repeat_penalty: number; - presence_penalty: number; - frequency_penalty: number; - dry_multiplier: number; - dry_base: number; - dry_allowed_length: number; - dry_penalty_last_n: number; - mirostat: number; - mirostat_tau: number; - mirostat_eta: number; - max_tokens: number; - n_keep: number; - n_discard: number; - ignore_eos: boolean; - stream: boolean; - n_probs: number; - min_keep: number; - chat_format: string; - reasoning_format: string; - reasoning_in_content: boolean; - generation_prompt: string; - samplers: string[]; - backend_sampling: boolean; - 'speculative.n_max': number; - 'speculative.n_min': number; - 'speculative.p_min': number; - timings_per_token: boolean; - post_sampling_probs: boolean; - lora: Array<{ name: string; scale: number }>; - }; - next_token: { - has_next_token: boolean; - has_new_line: boolean; - n_remain: number; - n_decoded: number; - }; -} - -export interface ApiProcessingState { - status: 'initializing' | 'generating' | 'preparing' | 'idle'; - tokensDecoded: number; - tokensRemaining: number; - contextUsed: number; - contextTotal: number | null; - outputTokensUsed: number; // Total output tokens (thinking + regular content) - outputTokensMax: number; // Max output tokens allowed - temperature: number; - topP: number; - speculative: boolean; - hasNextToken: boolean; - tokensPerSecond?: number; - // Progress information from prompt_progress - progressPercent?: number; - promptProgress?: ChatMessagePromptProgress; - promptTokens?: number; - promptMs?: number; - cacheTokens?: number; -} - -/** - * Router model metadata - extended from ApiModelDataEntry with additional router-specific fields - * @deprecated Use ApiModelDataEntry instead - the /models endpoint returns this structure directly - */ -export interface ApiRouterModelMeta { - /** Model identifier (e.g., "ggml-org/Qwen2.5-Omni-7B-GGUF:latest") */ - name: string; - /** Path to model file or manifest */ - path: string; - /** Optional path to multimodal projector */ - path_mmproj?: string; - /** Whether model is in HuggingFace cache */ - in_cache: boolean; - /** Port where model instance is running (0 if not loaded) */ - port?: number; - /** Current status of the model */ - status: ApiModelStatus; - /** Error message if status is FAILED */ - error?: string; -} - -/** - * Request to load a model - */ -export interface ApiRouterModelsLoadRequest { - model: string; -} - -/** - * Response from loading a model - */ -export interface ApiRouterModelsLoadResponse { - success: boolean; - error?: string; -} - -/** - * Request to check model status - */ -export interface ApiRouterModelsStatusRequest { - model: string; -} - -/** - * Response with model status - */ -export interface ApiRouterModelsStatusResponse { - model: string; - status: ModelStatus; - port?: number; - error?: string; -} - -/** - * Response with list of all models from /models endpoint - * Note: This is the same as ApiModelListResponse - the endpoint returns the same structure - * regardless of server mode (MODEL or ROUTER) - */ -export interface ApiRouterModelsListResponse { - object: string; - data: ApiModelDataEntry[]; -} - -/** - * Request to unload a model - */ -export interface ApiRouterModelsUnloadRequest { - model: string; -} - -/** - * Response from unloading a model - */ -export interface ApiRouterModelsUnloadResponse { - success: boolean; - error?: string; -} diff --git a/tools/server/webui/src/lib/types/chat.d.ts b/tools/server/webui/src/lib/types/chat.d.ts deleted file mode 100644 index acedd0769..000000000 --- a/tools/server/webui/src/lib/types/chat.d.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { ErrorDialogType } from '$lib/enums'; -import type { ApiChatCompletionToolCall } from './api'; -import type { DatabaseMessage, DatabaseMessageExtra } from './database'; - -export interface ChatUploadedFile { - id: string; - name: string; - size: number; - type: string; - file: File; - preview?: string; - textContent?: string; - mcpPrompt?: { - serverName: string; - promptName: string; - arguments?: Record; - }; - isLoading?: boolean; - loadError?: string; -} - -export interface ChatAttachmentDisplayItem { - id: string; - name: string; - size?: number; - preview?: string; - isImage: boolean; - isLoading?: boolean; - loadError?: string; - uploadedFile?: ChatUploadedFile; - attachment?: DatabaseMessageExtra; - attachmentIndex?: number; - textContent?: string; -} - -export interface ChatMessageSiblingInfo { - message: DatabaseMessage; - siblingIds: string[]; - currentIndex: number; - totalSiblings: number; -} - -export interface ChatMessagePromptProgress { - cache: number; - processed: number; - time_ms: number; - total: number; -} - -export interface ChatMessageTimings { - cache_n?: number; - predicted_ms?: number; - predicted_n?: number; - prompt_ms?: number; - prompt_n?: number; - agentic?: ChatMessageAgenticTimings; -} - -export interface ChatMessageAgenticTimings { - turns: number; - toolCallsCount: number; - toolsMs: number; - toolCalls?: ChatMessageToolCallTiming[]; - perTurn?: ChatMessageAgenticTurnStats[]; - llm: { - predicted_n: number; - predicted_ms: number; - prompt_n: number; - prompt_ms: number; - }; -} - -export interface ChatMessageAgenticTurnStats { - turn: number; - llm: { - predicted_n: number; - predicted_ms: number; - prompt_n: number; - prompt_ms: number; - }; - toolCalls: ChatMessageToolCallTiming[]; - toolsMs: number; -} - -export interface ChatMessageToolCallTiming { - name: string; - duration_ms: number; - success: boolean; -} - -/** - * Callbacks for streaming chat responses (used by both agentic and non-agentic paths) - */ -export interface ChatStreamCallbacks { - onChunk?: (chunk: string) => void; - onReasoningChunk?: (chunk: string) => void; - onToolCallsStreaming?: (toolCalls: ApiChatCompletionToolCall[]) => void; - onAttachments?: (messageId: string, extras: DatabaseMessageExtra[]) => void; - onModel?: (model: string) => void; - onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void; - onAssistantTurnComplete?: ( - content: string, - reasoningContent: string | undefined, - timings: ChatMessageTimings | undefined, - toolCalls: ApiChatCompletionToolCall[] | undefined - ) => Promise; - createToolResultMessage?: ( - toolCallId: string, - content: string, - extras?: DatabaseMessageExtra[] - ) => Promise; - createAssistantMessage?: () => Promise; - onFlowComplete?: (timings?: ChatMessageTimings) => void; - onError?: (error: Error) => void; - onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void; -} - -/** - * Error dialog state for displaying server/timeout errors - */ -export interface ErrorDialogState { - type: ErrorDialogType; - message: string; - contextInfo?: { n_prompt_tokens: number; n_ctx: number }; -} - -/** - * Live processing stats during prompt evaluation - */ -export interface LiveProcessingStats { - tokensProcessed: number; - totalTokens: number; - timeMs: number; - tokensPerSecond: number; - etaSecs?: number; -} - -/** - * Live generation stats during token generation - */ -export interface LiveGenerationStats { - tokensGenerated: number; - timeMs: number; - tokensPerSecond: number; -} - -/** - * Options for getting attachment display items - */ -export interface AttachmentDisplayItemsOptions { - uploadedFiles?: ChatUploadedFile[]; - attachments?: DatabaseMessageExtra[]; -} - -/** - * Result of file processing operation - */ -export interface FileProcessingResult { - extras: DatabaseMessageExtra[]; - emptyFiles: string[]; -} diff --git a/tools/server/webui/src/lib/types/common.d.ts b/tools/server/webui/src/lib/types/common.d.ts deleted file mode 100644 index 453d0cd74..000000000 --- a/tools/server/webui/src/lib/types/common.d.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { AttachmentType } from '$lib/enums'; - -/** - * Common utility types used across the application - */ - -/** - * Common utility types used across the application - */ - -/** - * Represents a key-value pair. - * Used for headers, environment variables, query parameters, etc. - */ -export interface KeyValuePair { - key: string; - value: string; -} - -/** - * Binary detection configuration options - */ -export interface BinaryDetectionOptions { - /** Number of characters to check from the beginning of the file */ - prefixLength: number; - /** Maximum ratio of suspicious characters allowed (0.0 to 1.0) */ - suspiciousCharThresholdRatio: number; - /** Maximum absolute number of null bytes allowed */ - maxAbsoluteNullBytes: number; -} - -/** - * Format for text attachments when copied to clipboard - */ -export interface ClipboardTextAttachment { - type: typeof AttachmentType.TEXT; - name: string; - content: string; -} - -/** - * Format for MCP prompt attachments when copied to clipboard - */ -export interface ClipboardMcpPromptAttachment { - type: typeof AttachmentType.MCP_PROMPT; - name: string; - serverName: string; - promptName: string; - content: string; - arguments?: Record; -} - -/** - * Union type for all clipboard attachment types - */ -export type ClipboardAttachment = ClipboardTextAttachment | ClipboardMcpPromptAttachment; - -/** - * Parsed result from clipboard content - */ -export interface ParsedClipboardContent { - message: string; - textAttachments: ClipboardTextAttachment[]; - mcpPromptAttachments: ClipboardMcpPromptAttachment[]; -} - -export type MimeTypeUnion = MimeTypeAudio | MimeTypeImage | MimeTypeApplication | MimeTypeText; diff --git a/tools/server/webui/src/lib/types/database.d.ts b/tools/server/webui/src/lib/types/database.d.ts deleted file mode 100644 index fde335de3..000000000 --- a/tools/server/webui/src/lib/types/database.d.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat'; -import { AttachmentType } from '$lib/enums'; - -export interface McpServerOverride { - serverId: string; - enabled: boolean; -} - -export interface DatabaseConversation { - currNode: string | null; - id: string; - lastModified: number; - name: string; - mcpServerOverrides?: McpServerOverride[]; - forkedFromConversationId?: string; -} - -export interface DatabaseMessageExtraAudioFile { - type: AttachmentType.AUDIO; - name: string; - size?: number; - base64Data: string; - mimeType: string; -} - -export interface DatabaseMessageExtraImageFile { - type: AttachmentType.IMAGE; - name: string; - size?: number; - base64Url: string; -} - -/** - * Legacy format from old webui - pasted content was stored as "context" type - * @deprecated Use DatabaseMessageExtraTextFile instead - */ -export interface DatabaseMessageExtraLegacyContext { - type: AttachmentType.LEGACY_CONTEXT; - name: string; - size?: number; - content: string; -} - -export interface DatabaseMessageExtraPdfFile { - type: AttachmentType.PDF; - base64Data: string; - name: string; - size?: number; - content: string; - images?: string[]; - processedAsImages: boolean; -} - -export interface DatabaseMessageExtraTextFile { - type: AttachmentType.TEXT; - name: string; - size?: number; - content: string; -} - -export interface DatabaseMessageExtraMcpPrompt { - type: AttachmentType.MCP_PROMPT; - name: string; - size?: number; - serverName: string; - promptName: string; - content: string; - arguments?: Record; -} - -export interface DatabaseMessageExtraMcpResource { - type: AttachmentType.MCP_RESOURCE; - name: string; - size?: number; - uri: string; - serverName: string; - content: string; - mimeType?: string; -} - -export type DatabaseMessageExtra = - | DatabaseMessageExtraImageFile - | DatabaseMessageExtraTextFile - | DatabaseMessageExtraAudioFile - | DatabaseMessageExtraPdfFile - | DatabaseMessageExtraMcpPrompt - | DatabaseMessageExtraMcpResource - | DatabaseMessageExtraLegacyContext; - -export interface DatabaseMessage { - id: string; - convId: string; - type: ChatMessageType; - timestamp: number; - role: ChatRole; - content: string; - parent: string | null; - /** - * @deprecated - left for backward compatibility - */ - thinking?: string; - /** Reasoning content produced by the model (separate from visible content) */ - reasoningContent?: string; - /** Serialized JSON array of tool calls made by assistant messages */ - toolCalls?: string; - /** Tool call ID for tool result messages (role: 'tool') */ - toolCallId?: string; - children: string[]; - extra?: DatabaseMessageExtra[]; - timings?: ChatMessageTimings; - model?: string; -} - -export type ExportedConversation = { - conv: DatabaseConversation; - messages: DatabaseMessage[]; -}; - -export type ExportedConversations = ExportedConversation | ExportedConversation[]; diff --git a/tools/server/webui/src/lib/types/index.ts b/tools/server/webui/src/lib/types/index.ts deleted file mode 100644 index 03cb9c5a5..000000000 --- a/tools/server/webui/src/lib/types/index.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Unified exports for all type definitions - * Import types from '$lib/types' for cleaner imports - */ - -// API types -export type { - ApiChatMessageContentPart, - ApiContextSizeError, - ApiErrorResponse, - ApiChatMessageData, - ApiModelStatus, - ApiModelDataEntry, - ApiModelDetails, - ApiModelListResponse, - ApiLlamaCppServerProps, - ApiChatCompletionRequest, - ApiChatCompletionToolCallFunctionDelta, - ApiChatCompletionToolCallDelta, - ApiChatCompletionToolCall, - ApiChatCompletionStreamChunk, - ApiChatCompletionResponse, - ApiSlotData, - ApiProcessingState, - ApiRouterModelMeta, - ApiRouterModelsLoadRequest, - ApiRouterModelsLoadResponse, - ApiRouterModelsStatusRequest, - ApiRouterModelsStatusResponse, - ApiRouterModelsListResponse, - ApiRouterModelsUnloadRequest, - ApiRouterModelsUnloadResponse -} from './api'; - -// Chat types -export type { - ChatUploadedFile, - ChatAttachmentDisplayItem, - ChatMessageSiblingInfo, - ChatMessagePromptProgress, - ChatMessageTimings, - ChatMessageAgenticTimings, - ChatMessageAgenticTurnStats, - ChatMessageToolCallTiming, - ChatStreamCallbacks, - ErrorDialogState, - LiveProcessingStats, - LiveGenerationStats, - AttachmentDisplayItemsOptions, - FileProcessingResult -} from './chat.d'; - -// Database types -export type { - McpServerOverride, - DatabaseConversation, - DatabaseMessageExtraAudioFile, - DatabaseMessageExtraImageFile, - DatabaseMessageExtraLegacyContext, - DatabaseMessageExtraMcpPrompt, - DatabaseMessageExtraMcpResource, - DatabaseMessageExtraPdfFile, - DatabaseMessageExtraTextFile, - DatabaseMessageExtra, - DatabaseMessage, - ExportedConversation, - ExportedConversations -} from './database'; - -// Model types -export type { ModelModalities, ModelOption, ModalityCapabilities } from './models'; - -// Settings types -export type { - SettingsConfigValue, - SettingsFieldConfig, - SettingsChatServiceOptions, - SettingsConfigType, - SettingsExportType, - ParameterValue, - ParameterRecord, - ParameterInfo, - SyncableParameter, - SettingsEntry, - SettingsSectionTitle, - SettingsSectionEntry, - SettingsSection -} from './settings'; - -// Common types -export type { - KeyValuePair, - BinaryDetectionOptions, - ClipboardTextAttachment, - ClipboardMcpPromptAttachment, - ClipboardAttachment, - ParsedClipboardContent -} from './common'; - -// MCP types -export type { - ClientCapabilities, - ServerCapabilities, - Implementation, - MCPConnectionLog, - MCPServerInfo, - MCPCapabilitiesInfo, - MCPToolInfo, - MCPPromptInfo, - MCPConnectionDetails, - MCPPhaseCallback, - MCPConnection, - HealthCheckState, - HealthCheckParams, - MCPServerConfig, - MCPClientConfig, - MCPServerSettingsEntry, - MCPToolCall, - OpenAIToolDefinition, - ServerStatus, - ToolCallParams, - ToolExecutionResult, - ServerBuiltinToolInfo, - Tool, - Prompt, - GetPromptResult, - PromptMessage, - MCPProgressState, - MCPResourceAnnotations, - MCPResourceIcon, - MCPResource, - MCPResourceTemplate, - MCPTextResourceContent, - MCPBlobResourceContent, - MCPResourceContent, - MCPReadResourceResult, - MCPResourceInfo, - MCPResourceTemplateInfo, - MCPCachedResource, - MCPResourceAttachment, - MCPResourceSubscription, - MCPServerResources -} from './mcp'; - -// Agentic types -export type { - AgenticConfig, - AgenticToolCallPayload, - AgenticMessage, - AgenticAssistantMessage, - AgenticToolCallList, - AgenticChatCompletionRequest, - AgenticSession, - AgenticFlowCallbacks, - AgenticFlowOptions, - AgenticFlowParams, - AgenticFlowResult, - SteeringMessage -} from './agentic'; - -// Tools types -export type { ToolEntry, ToolGroup } from './tools'; diff --git a/tools/server/webui/src/lib/types/mcp.d.ts b/tools/server/webui/src/lib/types/mcp.d.ts deleted file mode 100644 index 3837bcdf1..000000000 --- a/tools/server/webui/src/lib/types/mcp.d.ts +++ /dev/null @@ -1,432 +0,0 @@ -import type { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums/mcp'; -import type { ToolSource } from '$lib/enums/tools'; -import type { - Client, - ClientCapabilities as SDKClientCapabilities, - ServerCapabilities as SDKServerCapabilities, - Implementation as SDKImplementation, - Tool, - CallToolResult, - Prompt, - GetPromptResult, - PromptMessage, - Transport -} from '@modelcontextprotocol/sdk'; -import type { MimeTypeUnion } from './common'; -import type { ColorMode } from '$lib/enums'; - -export type { Tool, CallToolResult, Prompt, GetPromptResult, PromptMessage }; -export type ClientCapabilities = SDKClientCapabilities; -export type ServerCapabilities = SDKServerCapabilities; -export type Implementation = SDKImplementation; - -/** - * Log entry for connection events - */ -export interface MCPConnectionLog { - timestamp: Date; - phase: MCPConnectionPhase; - message: string; - details?: unknown; - level: MCPLogLevel; -} - -/** - * Server information returned after initialization - */ -export interface MCPServerInfo { - name: string; - version: string; - title?: string; - description?: string; - websiteUrl?: string; - icons?: MCPResourceIcon[]; -} - -/** - * Detailed capabilities information - */ -export interface MCPCapabilitiesInfo { - server: { - tools?: { listChanged?: boolean }; - prompts?: { listChanged?: boolean }; - resources?: { subscribe?: boolean; listChanged?: boolean }; - logging?: boolean; - completions?: boolean; - tasks?: boolean; - }; - client: { - roots?: { listChanged?: boolean }; - sampling?: boolean; - elicitation?: { form?: boolean; url?: boolean }; - tasks?: boolean; - }; -} - -/** - * Tool information for display - */ -export interface MCPToolInfo { - name: string; - description?: string; - title?: string; -} - -/** - * Prompt information for display - */ -export interface MCPPromptInfo { - name: string; - description?: string; - title?: string; - serverName: string; - arguments?: Array<{ - name: string; - description?: string; - required?: boolean; - }>; -} - -/** - * Full connection details for visualization - */ -export interface MCPConnectionDetails { - phase: MCPConnectionPhase; - transportType?: MCPTransportType; - protocolVersion?: string; - serverInfo?: MCPServerInfo; - capabilities?: MCPCapabilitiesInfo; - instructions?: string; - tools: MCPToolInfo[]; - connectionTimeMs?: number; - error?: string; - logs: MCPConnectionLog[]; -} - -/** - * Callback for connection phase changes - */ -export type MCPPhaseCallback = ( - phase: MCPConnectionPhase, - log: MCPConnectionLog, - details?: { - transportType?: MCPTransportType; - serverInfo?: MCPServerInfo; - serverCapabilities?: ServerCapabilities; - clientCapabilities?: ClientCapabilities; - protocolVersion?: string; - instructions?: string; - } -) => void; - -/** - * Represents an active MCP server connection. - * Returned by MCPService.connect() and used for subsequent operations. - */ -export interface MCPConnection { - client: Client; - transport: Transport; - tools: Tool[]; - serverName: string; - transportType: MCPTransportType; - serverInfo?: MCPServerInfo; - serverCapabilities?: ServerCapabilities; - clientCapabilities?: ClientCapabilities; - protocolVersion?: string; - instructions?: string; - connectionTimeMs: number; -} - -/** - * Extended health check state with detailed connection info - */ -export type HealthCheckState = - | { status: HealthCheckStatus.IDLE } - | { - status: HealthCheckStatus.CONNECTING; - phase: MCPConnectionPhase; - logs: MCPConnectionLog[]; - } - | { - status: HealthCheckStatus.ERROR; - message: string; - phase?: MCPConnectionPhase; - logs: MCPConnectionLog[]; - } - | { - status: HealthCheckStatus.SUCCESS; - tools: MCPToolInfo[]; - serverInfo?: MCPServerInfo; - capabilities?: MCPCapabilitiesInfo; - transportType?: MCPTransportType; - protocolVersion?: string; - instructions?: string; - connectionTimeMs?: number; - logs: MCPConnectionLog[]; - }; - -/** - * Health check parameters - */ -export interface HealthCheckParams { - id: string; - enabled: boolean; - url: string; - requestTimeoutSeconds: number; - headers?: string; - useProxy?: boolean; -} - -export type MCPServerConfig = { - transport?: MCPTransportType; - url: string; - protocols?: string | string[]; - headers?: Record; - credentials?: RequestCredentials; - handshakeTimeoutMs?: number; - requestTimeoutMs?: number; - capabilities?: ClientCapabilities; - useProxy?: boolean; -}; - -export type MCPClientConfig = { - servers: Record; - protocolVersion?: string; - capabilities?: ClientCapabilities; - clientInfo?: Implementation; - requestTimeoutMs?: number; -}; - -export type MCPToolCallArguments = Record; - -export type MCPToolCall = { - id: string; - function: { - name: string; - arguments: string | MCPToolCallArguments; - }; -}; - -export type MCPServerSettingsEntry = { - id: string; - enabled: boolean; - url: string; - requestTimeoutSeconds: number; - headers?: string; - name?: string; - iconUrl?: string; - useProxy?: boolean; -}; - -export interface MCPHostManagerConfig { - servers: MCPClientConfig['servers']; - clientInfo?: Implementation; - capabilities?: ClientCapabilities; -} - -export interface OpenAIToolDefinition { - type: 'function'; - function: { - name: string; - description?: string; - parameters: Record; - }; -} - -export interface ServerStatus { - name: string; - isConnected: boolean; - toolCount: number; - error?: string; -} - -export interface MCPServerConnectionConfig { - name: string; - server: MCPServerConfig; - clientInfo?: Implementation; - capabilities?: ClientCapabilities; -} - -export interface ToolCallParams { - name: string; - arguments: Record; -} - -export interface ToolExecutionResult { - content: string; - isError: boolean; -} - -export interface ServerBuiltinToolInfo { - display_name: string; - tool: string; - type: ToolSource.BUILTIN; - permissions: { - write: boolean; - }; - definition: OpenAIToolDefinition; -} - -/** - * Progress tracking state for a specific operation - */ -export interface MCPProgressState { - progressToken: string | number; - serverName: string; - progress: number; - total?: number; - message?: string; - startTime: Date; - lastUpdate: Date; -} - -/** - * Resource annotations for audience and priority hints - */ -export interface MCPResourceAnnotations { - audience?: ('user' | 'assistant')[]; - priority?: number; - lastModified?: string; -} - -/** - * Icon definition for resources - */ -export interface MCPResourceIcon { - src: string; - mimeType?: MimeTypeUnion; - sizes?: string[]; - theme?: ColorMode.LIGHT | ColorMode.DARK; -} - -/** - * A known resource that the server is capable of reading - */ -export interface MCPResource { - uri: string; - name: string; - title?: string; - description?: string; - mimeType?: MimeTypeUnion; - annotations?: MCPResourceAnnotations; - icons?: MCPResourceIcon[]; - _meta?: Record; -} - -/** - * A template for dynamically generating resource URIs - */ -export interface MCPResourceTemplate { - uriTemplate: string; - name: string; - title?: string; - description?: string; - mimeType?: MimeTypeUnion; - annotations?: MCPResourceAnnotations; - icons?: MCPResourceIcon[]; - _meta?: Record; -} - -/** - * Text content from a resource - */ -export interface MCPTextResourceContent { - uri: string; - mimeType?: MimeTypeUnion; - text: string; -} - -/** - * Binary (blob) content from a resource - */ -export interface MCPBlobResourceContent { - uri: string; - mimeType?: MimeTypeUnion; - /** Base64-encoded binary data */ - blob: string; -} - -/** - * Union type for resource content - */ -export type MCPResourceContent = MCPTextResourceContent | MCPBlobResourceContent; - -/** - * Result from reading a resource - */ -export interface MCPReadResourceResult { - contents: MCPResourceContent[]; - _meta?: Record; -} - -/** - * Resource information for display in UI - */ -export interface MCPResourceInfo { - uri: string; - name: string; - title?: string; - description?: string; - mimeType?: MimeTypeUnion; - serverName: string; - annotations?: MCPResourceAnnotations; - icons?: MCPResourceIcon[]; -} - -/** - * Resource template information for display in UI - */ -export interface MCPResourceTemplateInfo { - uriTemplate: string; - name: string; - title?: string; - description?: string; - mimeType?: MimeTypeUnion; - serverName: string; - annotations?: MCPResourceAnnotations; - icons?: MCPResourceIcon[]; -} - -/** - * Cached resource content with metadata - */ -export interface MCPCachedResource { - resource: MCPResourceInfo; - content: MCPResourceContent[]; - fetchedAt: Date; - /** Whether this resource has an active subscription */ - subscribed?: boolean; -} - -/** - * Resource attachment for chat context - */ -export interface MCPResourceAttachment { - id: string; - resource: MCPResourceInfo; - content?: MCPResourceContent[]; - loading?: boolean; - error?: string; -} - -/** - * State for resource subscriptions - */ -export interface MCPResourceSubscription { - uri: string; - serverName: string; - subscribedAt: Date; - lastUpdate?: Date; -} - -/** - * Aggregated resources state per server - */ -export interface MCPServerResources { - serverName: string; - resources: MCPResource[]; - templates: MCPResourceTemplate[]; - lastFetched?: Date; - loading: boolean; - error?: string; -} diff --git a/tools/server/webui/src/lib/types/models.d.ts b/tools/server/webui/src/lib/types/models.d.ts deleted file mode 100644 index b4d5f11f5..000000000 --- a/tools/server/webui/src/lib/types/models.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api'; - -export interface ModelModalities { - vision: boolean; - audio: boolean; -} - -export interface ModelOption { - id: string; - name: string; - model: string; - description?: string; - capabilities: string[]; - modalities?: ModelModalities; - details?: ApiModelDetails['details']; - meta?: ApiModelDataEntry['meta']; - parsedId?: ParsedModelId; - aliases?: string[]; - tags?: string[]; -} - -export interface ParsedModelId { - raw: string; - orgName: string | null; - modelName: string | null; - params: string | null; - activatedParams: string | null; - quantization: string | null; - tags: string[]; -} - -/** - * Modality capabilities for file validation - */ -export interface ModalityCapabilities { - hasVision: boolean; - hasAudio: boolean; -} diff --git a/tools/server/webui/src/lib/types/settings.d.ts b/tools/server/webui/src/lib/types/settings.d.ts deleted file mode 100644 index 1ab7a7e5d..000000000 --- a/tools/server/webui/src/lib/types/settings.d.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { SETTING_CONFIG_DEFAULT, SETTINGS_SECTION_TITLES } from '$lib/constants'; -import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat'; -import type { OpenAIToolDefinition } from './mcp'; -import type { DatabaseMessageExtra } from './database'; -import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums'; -import type { Icon } from '@lucide/svelte'; -import type { Component } from 'svelte'; - -export type SettingsConfigValue = string | number | boolean | undefined; - -/** Section title type derived from registry section titles. */ -export type SettingsSectionTitle = - (typeof SETTINGS_SECTION_TITLES)[keyof typeof SETTINGS_SECTION_TITLES]; - -/** Per-setting metadata — one entry per setting. */ -export interface SettingsEntry { - key: string; - label: string; - help: string; - defaultValue: SettingsConfigValue; - type: SettingsFieldType; - section?: string; - options?: Array<{ value: string; label: string; icon: Component }>; - isExperimental?: boolean; - isPositiveInteger?: boolean; - sync?: { - serverKey: string; - paramType: SyncableParameterType; - }; -} - -/** A settings section with its icon, slug, title, and ordered settings. */ -export interface SettingsSectionEntry { - title: SettingsSectionTitle; - slug: string; - icon: Component; - settings: SettingsEntry[]; -} - -export interface SettingsFieldConfig { - key: string; - label: string; - type: SettingsFieldType; - isExperimental?: boolean; - help?: string; - options?: Array<{ value: string; label: string; icon?: typeof Icon }>; -} - -/** Re-exported for backward compatibility. */ -export interface SettingsSection { - fields?: SettingsFieldConfig[]; - icon: Component; - slug: string; - title: SettingsSectionTitle; -} - -export interface SettingsChatServiceOptions { - stream?: boolean; - // Model (required in ROUTER mode, optional in MODEL mode) - model?: string; - // System message to inject - systemMessage?: string; - // Disable reasoning parsing (use 'none' instead of 'auto') - disableReasoningParsing?: boolean; - // Strip reasoning content from context before sending - excludeReasoningFromContext?: boolean; - tools?: OpenAIToolDefinition[]; - // Generation parameters - temperature?: number; - max_tokens?: number; - // Sampling parameters - dynatemp_range?: number; - dynatemp_exponent?: number; - top_k?: number; - top_p?: number; - min_p?: number; - xtc_probability?: number; - xtc_threshold?: number; - typ_p?: number; - // Penalty parameters - repeat_last_n?: number; - repeat_penalty?: number; - presence_penalty?: number; - frequency_penalty?: number; - dry_multiplier?: number; - dry_base?: number; - dry_allowed_length?: number; - dry_penalty_last_n?: number; - // Sampler configuration - samplers?: string | string[]; - backend_sampling?: boolean; - // Custom parameters - custom?: string; - timings_per_token?: boolean; - // Continuation control (vLLM compat), opt in to the explicit continue final message flag - continueFinalMessage?: boolean; - // Callbacks - onChunk?: (chunk: string) => void; - onReasoningChunk?: (chunk: string) => void; - onToolCallChunk?: (chunk: string) => void; - onAttachments?: (extras: DatabaseMessageExtra[]) => void; - onModel?: (model: string) => void; - onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void; - onComplete?: ( - response: string, - reasoningContent?: string, - timings?: ChatMessageTimings, - toolCalls?: string - ) => void; - onError?: (error: Error) => void; -} - -export type SettingsConfigType = typeof SETTING_CONFIG_DEFAULT & { - [key: string]: SettingsConfigValue; -}; - -/** - * Parameter synchronization types for server defaults and user overrides - * Note: ParameterSource and SyncableParameterType enums are imported from '$lib/enums' - */ -export type ParameterValue = string | number | boolean; -export type ParameterRecord = Record; - -export interface ParameterInfo { - value: string | number | boolean; - source: ParameterSource; - serverDefault?: string | number | boolean; - userOverride?: string | number | boolean; -} - -export interface SyncableParameter { - key: string; - serverKey: string; - type: SyncableParameterType; - canSync: boolean; -} - -/** - * Shape of the settings JSON export file. - * Versioned to allow future schema evolution. - */ -export interface SettingsExportType { - /** Export format version — bumped on breaking changes */ - version: number; - /** Unix timestamp of export */ - timestamp: number; - /** Full settings config (includes theme as a config key) */ - config: SettingsConfigType; - /** Keys that differ from server defaults (derived, but persisted for fidelity) */ - userOverrides: string[]; -} diff --git a/tools/server/webui/src/lib/types/tools.d.ts b/tools/server/webui/src/lib/types/tools.d.ts deleted file mode 100644 index a17a0c9a9..000000000 --- a/tools/server/webui/src/lib/types/tools.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ToolSource } from '$lib/enums'; -import type { OpenAIToolDefinition } from './mcp'; - -export interface ToolEntry { - source: ToolSource; - /** For MCP tools, the server display name (used for UI grouping) */ - serverName?: string; - /** For MCP tools, the server ID (used for permission keys) */ - serverId?: string; - definition: OpenAIToolDefinition; -} - -export interface ToolGroup { - source: ToolSource; - label: string; - /** For MCP groups, the server ID */ - serverId?: string; - tools: OpenAIToolDefinition[]; -} diff --git a/tools/server/webui/src/lib/utils/abort.ts b/tools/server/webui/src/lib/utils/abort.ts deleted file mode 100644 index fc4f31ec6..000000000 --- a/tools/server/webui/src/lib/utils/abort.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Abort Signal Utilities - * - * Provides utilities for consistent AbortSignal propagation across the application. - * These utilities help ensure that async operations can be properly cancelled - * when needed (e.g., user stops generation, navigates away, etc.). - */ - -/** - * Throws an AbortError if the signal is aborted. - * Use this at the start of async operations to fail fast. - * - * @param signal - Optional AbortSignal to check - * @throws DOMException with name 'AbortError' if signal is aborted - * - * @example - * ```ts - * async function fetchData(signal?: AbortSignal) { - * throwIfAborted(signal); - * // ... proceed with operation - * } - * ``` - */ -export function throwIfAborted(signal?: AbortSignal): void { - if (signal?.aborted) { - throw new DOMException('Operation was aborted', 'AbortError'); - } -} - -/** - * Checks if an error is an AbortError. - * Use this to distinguish between user-initiated cancellation and actual errors. - * - * @param error - Error to check - * @returns true if the error is an AbortError - * - * @example - * ```ts - * try { - * await fetchData(signal); - * } catch (error) { - * if (isAbortError(error)) { - * // User cancelled - no error dialog needed - * return; - * } - * // Handle actual error - * } - * ``` - */ -export function isAbortError(error: unknown): boolean { - if (error instanceof DOMException && error.name === 'AbortError') { - return true; - } - if (error instanceof Error && error.name === 'AbortError') { - return true; - } - return false; -} - -/** - * Creates a new AbortController that is linked to one or more parent signals. - * When any parent signal aborts, the returned controller also aborts. - * - * Useful for creating child operations that should be cancelled when - * either the parent operation or their own timeout/condition triggers. - * - * @param signals - Parent signals to link to (undefined signals are ignored) - * @returns A new AbortController linked to all provided signals - * - * @example - * ```ts - * // Link to user's abort signal and add a timeout - * const linked = createLinkedController(userSignal, timeoutSignal); - * await fetch(url, { signal: linked.signal }); - * ``` - */ -export function createLinkedController(...signals: (AbortSignal | undefined)[]): AbortController { - const controller = new AbortController(); - - for (const signal of signals) { - if (!signal) continue; - - // If already aborted, abort immediately - if (signal.aborted) { - controller.abort(signal.reason); - return controller; - } - - // Link to parent signal - signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true }); - } - - return controller; -} - -/** - * Creates an AbortSignal that times out after the specified duration. - * - * @param ms - Timeout duration in milliseconds - * @returns AbortSignal that will abort after the timeout - * - * @example - * ```ts - * const signal = createTimeoutSignal(5000); // 5 second timeout - * await fetch(url, { signal }); - * ``` - */ -export function createTimeoutSignal(ms: number): AbortSignal { - return AbortSignal.timeout(ms); -} - -/** - * Wraps a promise to reject if the signal is aborted. - * Useful for making non-abortable promises respect an AbortSignal. - * - * @param promise - Promise to wrap - * @param signal - AbortSignal to respect - * @returns Promise that rejects with AbortError if signal aborts - * - * @example - * ```ts - * // Make a non-abortable operation respect abort signal - * const result = await withAbortSignal( - * someNonAbortableOperation(), - * signal - * ); - * ``` - */ -export async function withAbortSignal(promise: Promise, signal?: AbortSignal): Promise { - if (!signal) return promise; - - throwIfAborted(signal); - - return new Promise((resolve, reject) => { - const abortHandler = () => { - reject(new DOMException('Operation was aborted', 'AbortError')); - }; - - signal.addEventListener('abort', abortHandler, { once: true }); - - promise - .then((value) => { - signal.removeEventListener('abort', abortHandler); - resolve(value); - }) - .catch((error) => { - signal.removeEventListener('abort', abortHandler); - reject(error); - }); - }); -} diff --git a/tools/server/webui/src/lib/utils/agentic.ts b/tools/server/webui/src/lib/utils/agentic.ts deleted file mode 100644 index 549a1c9a0..000000000 --- a/tools/server/webui/src/lib/utils/agentic.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { AgenticSectionType, MessageRole } from '$lib/enums'; -import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants'; -import type { ApiChatCompletionToolCall } from '$lib/types/api'; -import type { - DatabaseMessage, - DatabaseMessageExtra, - DatabaseMessageExtraImageFile -} from '$lib/types/database'; -import { AttachmentType } from '$lib/enums'; - -/** - * Represents a parsed section of agentic content for display - */ -export interface AgenticSection { - type: AgenticSectionType; - content: string; - toolName?: string; - toolArgs?: string; - toolResult?: string; - toolResultExtras?: DatabaseMessageExtra[]; -} - -/** - * Represents a tool result line that may reference an image attachment - */ -export type ToolResultLine = { - text: string; - image?: DatabaseMessageExtraImageFile; -}; - -/** - * Derives display sections from a single assistant message and its direct tool results. - * - * @param message - The assistant message - * @param toolMessages - Tool result messages for this assistant's tool_calls - * @param streamingToolCalls - Partial tool calls during streaming (not yet persisted) - */ -function deriveSingleTurnSections( - message: DatabaseMessage, - toolMessages: DatabaseMessage[] = [], - streamingToolCalls: ApiChatCompletionToolCall[] = [], - isStreaming: boolean = false -): AgenticSection[] { - const sections: AgenticSection[] = []; - - // 1. Reasoning content (from dedicated field) - if (message.reasoningContent) { - const toolCalls = parseToolCalls(message.toolCalls); - const hasContentAfterReasoning = - !!message.content?.trim() || toolCalls.length > 0 || streamingToolCalls.length > 0; - const isPending = isStreaming && !hasContentAfterReasoning; - sections.push({ - type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING, - content: message.reasoningContent - }); - } - - // 2. Text content - if (message.content?.trim()) { - sections.push({ - type: AgenticSectionType.TEXT, - content: message.content - }); - } - - // 3. Persisted tool calls (from message.toolCalls field) - const toolCalls = parseToolCalls(message.toolCalls); - for (const tc of toolCalls) { - const resultMsg = toolMessages.find((m) => m.toolCallId === tc.id); - // Only show as pending/loading if we're actively streaming; otherwise it's just a tool call without result - const type = resultMsg - ? AgenticSectionType.TOOL_CALL - : isStreaming - ? AgenticSectionType.TOOL_CALL_PENDING - : AgenticSectionType.TOOL_CALL; - sections.push({ - type, - content: resultMsg?.content || '', - toolName: tc.function?.name, - toolArgs: tc.function?.arguments, - toolResult: resultMsg?.content, - toolResultExtras: resultMsg?.extra - }); - } - - // 4. Streaming tool calls (not yet persisted - currently being received) - for (const tc of streamingToolCalls) { - // Skip if already in persisted tool calls - if (tc.id && toolCalls.find((t) => t.id === tc.id)) continue; - sections.push({ - type: AgenticSectionType.TOOL_CALL_STREAMING, - content: '', - toolName: tc.function?.name, - toolArgs: tc.function?.arguments - }); - } - - return sections; -} - -/** - * Derives display sections from structured message data. - * - * Handles both single-turn (one assistant + its tool results) and multi-turn - * agentic sessions (multiple assistant + tool messages grouped together). - * - * When `toolMessages` contains continuation assistant messages (from multi-turn - * agentic flows), they are processed in order to produce sections across all turns. - * - * @param message - The first/anchor assistant message - * @param toolMessages - Tool result messages and continuation assistant messages - * @param streamingToolCalls - Partial tool calls during streaming (not yet persisted) - * @param isStreaming - Whether the message is currently being streamed - */ -export function deriveAgenticSections( - message: DatabaseMessage, - toolMessages: DatabaseMessage[] = [], - streamingToolCalls: ApiChatCompletionToolCall[] = [], - isStreaming: boolean = false -): AgenticSection[] { - const hasAssistantContinuations = toolMessages.some((m) => m.role === MessageRole.ASSISTANT); - - if (!hasAssistantContinuations) { - return deriveSingleTurnSections(message, toolMessages, streamingToolCalls, isStreaming); - } - - const sections: AgenticSection[] = []; - - const firstTurnToolMsgs = collectToolMessages(toolMessages, 0); - sections.push(...deriveSingleTurnSections(message, firstTurnToolMsgs)); - - let i = firstTurnToolMsgs.length; - - while (i < toolMessages.length) { - const msg = toolMessages[i]; - - if (msg.role === MessageRole.ASSISTANT) { - const turnToolMsgs = collectToolMessages(toolMessages, i + 1); - const isLastTurn = i + 1 + turnToolMsgs.length >= toolMessages.length; - - sections.push( - ...deriveSingleTurnSections( - msg, - turnToolMsgs, - isLastTurn ? streamingToolCalls : [], - isLastTurn && isStreaming - ) - ); - - i += 1 + turnToolMsgs.length; - } else { - i++; - } - } - - return sections; -} - -/** - * Collect consecutive tool messages starting at `startIndex`. - */ -function collectToolMessages(messages: DatabaseMessage[], startIndex: number): DatabaseMessage[] { - const result: DatabaseMessage[] = []; - - for (let i = startIndex; i < messages.length; i++) { - if (messages[i].role === MessageRole.TOOL) { - result.push(messages[i]); - } else { - break; - } - } - - return result; -} - -/** - * Parse tool result text into lines, matching image attachments by name. - */ -export function parseToolResultWithImages( - toolResult: string, - extras?: DatabaseMessageExtra[] -): ToolResultLine[] { - const lines = toolResult.split(NEWLINE_SEPARATOR); - return lines.map((line) => { - const match = line.match(ATTACHMENT_SAVED_REGEX); - if (!match || !extras) return { text: line }; - - const attachmentName = match[1]; - const image = extras.find( - (e): e is DatabaseMessageExtraImageFile => - e.type === AttachmentType.IMAGE && e.name === attachmentName - ); - - return { text: line, image }; - }); -} - -/** - * Safely parse the toolCalls JSON string from a DatabaseMessage. - */ -function parseToolCalls(toolCallsJson?: string): ApiChatCompletionToolCall[] { - if (!toolCallsJson) return []; - - try { - const parsed = JSON.parse(toolCallsJson); - - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -/** - * Check if a message has agentic content (tool calls or is part of an agentic flow). - */ -export function hasAgenticContent( - message: DatabaseMessage, - toolMessages: DatabaseMessage[] = [] -): boolean { - if (message.toolCalls) { - const tc = parseToolCalls(message.toolCalls); - - if (tc.length > 0) return true; - } - - return toolMessages.length > 0; -} diff --git a/tools/server/webui/src/lib/utils/api-fetch.ts b/tools/server/webui/src/lib/utils/api-fetch.ts deleted file mode 100644 index 80781b98e..000000000 --- a/tools/server/webui/src/lib/utils/api-fetch.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { base } from '$app/paths'; -import { getJsonHeaders, getAuthHeaders } from './api-headers'; -import { UrlProtocol } from '$lib/enums'; - -/** - * API Fetch Utilities - * - * Provides common fetch patterns used across services: - * - Automatic JSON headers - * - Error handling with proper error messages - * - Base path resolution - */ - -export interface ApiFetchOptions extends Omit { - /** - * Use auth-only headers (no Content-Type). - * Default: false (uses JSON headers with Content-Type: application/json) - */ - authOnly?: boolean; - /** - * Additional headers to merge with default headers. - */ - headers?: Record; -} - -/** - * Fetch JSON data from an API endpoint with standard headers and error handling. - * - * @param path - API path (will be prefixed with base path) - * @param options - Fetch options with additional authOnly flag - * @returns Parsed JSON response - * @throws Error with formatted message on failure - * - * @example - * ```typescript - * // GET request - * const models = await apiFetch('/v1/models'); - * - * // POST request - * const result = await apiFetch('/models/load', { - * method: 'POST', - * body: JSON.stringify({ model: 'gpt-4' }) - * }); - * ``` - */ -export async function apiFetch(path: string, options: ApiFetchOptions = {}): Promise { - const { authOnly = false, headers: customHeaders, ...fetchOptions } = options; - - const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders(); - const headers = { ...baseHeaders, ...customHeaders }; - - const url = - path.startsWith(UrlProtocol.HTTP) || path.startsWith(UrlProtocol.HTTPS) - ? path - : `${base}${path}`; - - const response = await fetch(url, { - ...fetchOptions, - headers - }); - - if (!response.ok) { - const errorMessage = await parseErrorMessage(response); - throw new Error(errorMessage); - } - - return response.json() as Promise; -} - -/** - * Fetch with URL constructed from base URL and query parameters. - * - * @param basePath - Base API path - * @param params - Query parameters to append - * @param options - Fetch options - * @returns Parsed JSON response - * - * @example - * ```typescript - * const props = await apiFetchWithParams('./props', { - * model: 'gpt-4', - * autoload: 'false' - * }); - * ``` - */ -export async function apiFetchWithParams( - basePath: string, - params: Record, - options: ApiFetchOptions = {} -): Promise { - const url = new URL(basePath, window.location.href); - - for (const [key, value] of Object.entries(params)) { - if (value !== undefined && value !== null) { - url.searchParams.set(key, value); - } - } - - const { authOnly = false, headers: customHeaders, ...fetchOptions } = options; - - const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders(); - const headers = { ...baseHeaders, ...customHeaders }; - - const response = await fetch(url.toString(), { - ...fetchOptions, - headers - }); - - if (!response.ok) { - const errorMessage = await parseErrorMessage(response); - throw new Error(errorMessage); - } - - return response.json() as Promise; -} - -/** - * POST JSON data to an API endpoint. - * - * @param path - API path - * @param body - Request body (will be JSON stringified) - * @param options - Additional fetch options - * @returns Parsed JSON response - */ -export async function apiPost( - path: string, - body: B, - options: ApiFetchOptions = {} -): Promise { - return apiFetch(path, { - method: 'POST', - body: JSON.stringify(body), - ...options - }); -} - -/** - * Parse error message from a failed response. - * Tries to extract error message from JSON body, falls back to status text. - */ -async function parseErrorMessage(response: Response): Promise { - try { - const errorData = await response.json(); - if (errorData?.error?.message) { - return errorData.error.message; - } - if (errorData?.error && typeof errorData.error === 'string') { - return errorData.error; - } - if (errorData?.message) { - return errorData.message; - } - } catch { - // JSON parsing failed, use status text - } - - return `Request failed: ${response.status} ${response.statusText}`; -} diff --git a/tools/server/webui/src/lib/utils/api-headers.ts b/tools/server/webui/src/lib/utils/api-headers.ts deleted file mode 100644 index c0a5309b9..000000000 --- a/tools/server/webui/src/lib/utils/api-headers.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { config } from '$lib/stores/settings.svelte'; -import { REDACTED_HEADERS } from '$lib/constants'; -import { redactValue } from './redact'; - -/** - * Get authorization headers for API requests - * Includes Bearer token if API key is configured - */ -export function getAuthHeaders(): Record { - const currentConfig = config(); - const apiKey = currentConfig.apiKey?.toString().trim(); - - return apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; -} - -/** - * Get standard JSON headers with optional authorization - */ -export function getJsonHeaders(): Record { - return { - 'Content-Type': 'application/json', - ...getAuthHeaders() - }; -} - -/** - * Sanitize HTTP headers by redacting sensitive values. - * Known sensitive headers (from REDACTED_HEADERS) and any extra headers - * specified by the caller are fully redacted. Headers listed in - * `partialRedactHeaders` are partially redacted, showing only the - * specified number of trailing characters. - * - * @param headers - Headers to sanitize - * @param extraRedactedHeaders - Additional header names to fully redact - * @param partialRedactHeaders - Map of header name -> number of trailing chars to keep visible - * @returns Object with header names as keys and (possibly redacted) values - */ -export function sanitizeHeaders( - headers?: HeadersInit, - extraRedactedHeaders?: Iterable, - partialRedactHeaders?: Map -): Record { - if (!headers) { - return {}; - } - - const normalized = new Headers(headers); - const sanitized: Record = {}; - const redactedHeaders = new Set( - Array.from(extraRedactedHeaders ?? [], (header) => header.toLowerCase()) - ); - - for (const [key, value] of normalized.entries()) { - const normalizedKey = key.toLowerCase(); - const partialChars = partialRedactHeaders?.get(normalizedKey); - - if (partialChars !== undefined) { - sanitized[key] = redactValue(value, partialChars); - } else if (REDACTED_HEADERS.has(normalizedKey) || redactedHeaders.has(normalizedKey)) { - sanitized[key] = redactValue(value); - } else { - sanitized[key] = value; - } - } - - return sanitized; -} diff --git a/tools/server/webui/src/lib/utils/api-key-validation.ts b/tools/server/webui/src/lib/utils/api-key-validation.ts deleted file mode 100644 index 948b7d7b6..000000000 --- a/tools/server/webui/src/lib/utils/api-key-validation.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { base } from '$app/paths'; -import { error } from '@sveltejs/kit'; -import { browser } from '$app/environment'; -import { config } from '$lib/stores/settings.svelte'; - -/** - * Validates API key by making a request to the server props endpoint - * Throws SvelteKit errors for authentication failures or server issues - */ -export async function validateApiKey(fetch: typeof globalThis.fetch): Promise { - if (!browser) { - return; - } - - try { - const apiKey = config().apiKey; - - const headers: Record = { - 'Content-Type': 'application/json' - }; - - if (apiKey) { - headers.Authorization = `Bearer ${apiKey}`; - } - - const response = await fetch(`${base}/props`, { headers }); - - if (!response.ok) { - if (response.status === 401 || response.status === 403) { - throw error(401, 'Access denied'); - } - - console.warn(`Server responded with status ${response.status} during API key validation`); - return; - } - } catch (err) { - // If it's already a SvelteKit error, re-throw it - if (err && typeof err === 'object' && 'status' in err) { - throw err; - } - - // Network or other errors - console.warn('Cannot connect to server for API key validation:', err); - } -} diff --git a/tools/server/webui/src/lib/utils/attachment-display.ts b/tools/server/webui/src/lib/utils/attachment-display.ts deleted file mode 100644 index 30c7043bf..000000000 --- a/tools/server/webui/src/lib/utils/attachment-display.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { AttachmentType, FileTypeCategory, SpecialFileType } from '$lib/enums'; -import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils'; -import type { - AttachmentDisplayItemsOptions, - ChatAttachmentDisplayItem, - ChatUploadedFile -} from '$lib/types'; - -/** - * Check if a display item represents an MCP prompt - * (either from attachment type or uploaded file with mcpPrompt metadata) - */ -export function isMcpPrompt(item: ChatAttachmentDisplayItem): boolean { - if (item.attachment?.type === AttachmentType.MCP_PROMPT) { - return true; - } - if (item.uploadedFile?.type === SpecialFileType.MCP_PROMPT && item.uploadedFile.mcpPrompt) { - return true; - } - return false; -} - -/** - * Check if a display item represents an MCP resource - */ -export function isMcpResource(item: ChatAttachmentDisplayItem): boolean { - return item.attachment?.type === AttachmentType.MCP_RESOURCE; -} - -/** - * Gets the file type category from an uploaded file, checking both MIME type and extension - */ -function getUploadedFileCategory(file: ChatUploadedFile): FileTypeCategory | null { - const categoryByMime = getFileTypeCategory(file.type); - - if (categoryByMime) { - return categoryByMime; - } - - return getFileTypeCategoryByExtension(file.name); -} - -/** - * Creates a unified list of display items from uploaded files and stored attachments. - * Items are returned in reverse order (newest first). - */ -export function getAttachmentDisplayItems( - options: AttachmentDisplayItemsOptions -): ChatAttachmentDisplayItem[] { - const { uploadedFiles = [], attachments = [] } = options; - const items: ChatAttachmentDisplayItem[] = []; - - // Add uploaded files (ChatForm) - for (const file of uploadedFiles) { - items.push({ - id: file.id, - name: file.name, - size: file.size, - preview: file.preview, - isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE, - isLoading: file.isLoading, - loadError: file.loadError, - uploadedFile: file, - textContent: file.textContent - }); - } - - // Add stored attachments (ChatMessage) - for (const [index, attachment] of attachments.entries()) { - const isImage = isImageFile(attachment); - - items.push({ - id: `attachment-${index}`, - name: attachment.name, - size: 'size' in attachment ? attachment.size : undefined, - preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined, - isImage, - attachment, - attachmentIndex: index, - textContent: 'content' in attachment ? attachment.content : undefined - }); - } - - return items.reverse(); -} diff --git a/tools/server/webui/src/lib/utils/attachment-type.ts b/tools/server/webui/src/lib/utils/attachment-type.ts deleted file mode 100644 index 9e9f09601..000000000 --- a/tools/server/webui/src/lib/utils/attachment-type.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { AttachmentType, FileTypeCategory } from '$lib/enums'; -import { getFileTypeCategory, getFileTypeCategoryByExtension } from '$lib/utils'; - -/** - * Gets the file type category from an uploaded file, checking both MIME type and extension - * @param uploadedFile - The uploaded file to check - * @returns The file type category or null if not recognized - */ -function getUploadedFileCategory(uploadedFile: ChatUploadedFile): FileTypeCategory | null { - // First try MIME type - const categoryByMime = getFileTypeCategory(uploadedFile.type); - - if (categoryByMime) { - return categoryByMime; - } - - // Fallback to extension (browsers don't always provide correct MIME types) - return getFileTypeCategoryByExtension(uploadedFile.name); -} - -/** - * Determines if an attachment or uploaded file is a text file - * @param uploadedFile - Optional uploaded file - * @param attachment - Optional database attachment - * @returns true if the file is a text file - */ -export function isTextFile( - attachment?: DatabaseMessageExtra, - uploadedFile?: ChatUploadedFile -): boolean { - if (uploadedFile) { - return getUploadedFileCategory(uploadedFile) === FileTypeCategory.TEXT; - } - - if (attachment) { - return ( - attachment.type === AttachmentType.TEXT || attachment.type === AttachmentType.LEGACY_CONTEXT - ); - } - - return false; -} - -/** - * Determines if an attachment or uploaded file is an image - * @param uploadedFile - Optional uploaded file - * @param attachment - Optional database attachment - * @returns true if the file is an image - */ -export function isImageFile( - attachment?: DatabaseMessageExtra, - uploadedFile?: ChatUploadedFile -): boolean { - if (uploadedFile) { - return getUploadedFileCategory(uploadedFile) === FileTypeCategory.IMAGE; - } - - if (attachment) { - return attachment.type === AttachmentType.IMAGE; - } - - return false; -} - -/** - * Determines if an attachment or uploaded file is a PDF - * @param uploadedFile - Optional uploaded file - * @param attachment - Optional database attachment - * @returns true if the file is a PDF - */ -export function isPdfFile( - attachment?: DatabaseMessageExtra, - uploadedFile?: ChatUploadedFile -): boolean { - if (uploadedFile) { - return getUploadedFileCategory(uploadedFile) === FileTypeCategory.PDF; - } - - if (attachment) { - return attachment.type === AttachmentType.PDF; - } - - return false; -} - -/** - * Determines if an attachment or uploaded file is an audio file - * @param uploadedFile - Optional uploaded file - * @param attachment - Optional database attachment - * @returns true if the file is an audio file - */ -export function isAudioFile( - attachment?: DatabaseMessageExtra, - uploadedFile?: ChatUploadedFile -): boolean { - if (uploadedFile) { - return getUploadedFileCategory(uploadedFile) === FileTypeCategory.AUDIO; - } - - if (attachment) { - return attachment.type === AttachmentType.AUDIO; - } - - return false; -} diff --git a/tools/server/webui/src/lib/utils/audio-recording.ts b/tools/server/webui/src/lib/utils/audio-recording.ts deleted file mode 100644 index ab207b7a4..000000000 --- a/tools/server/webui/src/lib/utils/audio-recording.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { MimeTypeAudio } from '$lib/enums'; - -/** - * AudioRecorder - Browser-based audio recording with MediaRecorder API - * - * This class provides a complete audio recording solution using the browser's MediaRecorder API. - * It handles microphone access, recording state management, and audio format optimization. - * - * **Features:** - * - Automatic microphone permission handling - * - Audio enhancement (echo cancellation, noise suppression, auto gain) - * - Multiple format support with fallback (WAV, WebM, MP4, AAC) - * - Real-time recording state tracking - * - Proper cleanup and resource management - */ -export class AudioRecorder { - private mediaRecorder: MediaRecorder | null = null; - private audioChunks: Blob[] = []; - private stream: MediaStream | null = null; - private recordingState: boolean = false; - - async startRecording(): Promise { - try { - this.stream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true - } - }); - - this.initializeRecorder(this.stream); - - this.audioChunks = []; - // Start recording with a small timeslice to ensure we get data - this.mediaRecorder!.start(100); - this.recordingState = true; - } catch (error) { - console.error('Failed to start recording:', error); - throw new Error('Failed to access microphone. Please check permissions.'); - } - } - - async stopRecording(): Promise { - return new Promise((resolve, reject) => { - const recorder = this.mediaRecorder; - const chunks = this.audioChunks; - const stream = this.stream; - - if (!recorder || recorder.state === 'inactive') { - reject(new Error('No active recording to stop')); - return; - } - - // Detach instance state right away so a new startRecording can take over without race - this.mediaRecorder = null; - this.audioChunks = []; - this.stream = null; - this.recordingState = false; - - recorder.onstop = () => { - const audioBlob = new Blob(chunks, { - type: recorder.mimeType || MimeTypeAudio.WAV - }); - - if (stream) { - for (const track of stream.getTracks()) { - track.stop(); - } - } - - resolve(audioBlob); - }; - - recorder.onerror = (event) => { - console.error('Recording error:', event); - - if (stream) { - for (const track of stream.getTracks()) { - track.stop(); - } - } - - reject(new Error('Recording failed')); - }; - - recorder.stop(); - }); - } - - isRecording(): boolean { - return this.recordingState; - } - - cancelRecording(): void { - const recorder = this.mediaRecorder; - const stream = this.stream; - - this.mediaRecorder = null; - this.audioChunks = []; - this.stream = null; - this.recordingState = false; - - if (recorder && recorder.state !== 'inactive') { - // Drop the original handlers so the pending stop event does not touch the instance - recorder.onstop = null; - recorder.onerror = null; - recorder.stop(); - } - - if (stream) { - for (const track of stream.getTracks()) { - track.stop(); - } - } - } - - private initializeRecorder(stream: MediaStream): void { - const options: MediaRecorderOptions = {}; - - if (MediaRecorder.isTypeSupported(MimeTypeAudio.WAV)) { - options.mimeType = MimeTypeAudio.WAV; - } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM_OPUS)) { - options.mimeType = MimeTypeAudio.WEBM_OPUS; - } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM)) { - options.mimeType = MimeTypeAudio.WEBM; - } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.MP4)) { - options.mimeType = MimeTypeAudio.MP4; - } else { - console.warn('No preferred audio format supported, using default'); - } - - this.mediaRecorder = new MediaRecorder(stream, options); - - this.mediaRecorder.ondataavailable = (event) => { - if (event.data.size > 0) { - this.audioChunks.push(event.data); - } - }; - - this.mediaRecorder.onstop = () => { - this.recordingState = false; - }; - - this.mediaRecorder.onerror = (event) => { - console.error('MediaRecorder error:', event); - this.recordingState = false; - }; - } -} - -export async function convertToWav(audioBlob: Blob): Promise { - try { - if (audioBlob.type.includes('wav')) { - return audioBlob; - } - - const arrayBuffer = await audioBlob.arrayBuffer(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); - - try { - const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); - return audioBufferToWav(audioBuffer); - } finally { - audioContext.close(); - } - } catch (error) { - console.error('Failed to convert audio to WAV:', error); - return audioBlob; - } -} - -function audioBufferToWav(buffer: AudioBuffer): Blob { - const length = buffer.length; - const numberOfChannels = buffer.numberOfChannels; - const sampleRate = buffer.sampleRate; - const bytesPerSample = 2; // 16-bit - const blockAlign = numberOfChannels * bytesPerSample; - const byteRate = sampleRate * blockAlign; - const dataSize = length * blockAlign; - const bufferSize = 44 + dataSize; - - const arrayBuffer = new ArrayBuffer(bufferSize); - const view = new DataView(arrayBuffer); - - const writeString = (offset: number, string: string) => { - for (let i = 0; i < string.length; i++) { - view.setUint8(offset + i, string.charCodeAt(i)); - } - }; - - writeString(0, 'RIFF'); // ChunkID - view.setUint32(4, bufferSize - 8, true); // ChunkSize - writeString(8, 'WAVE'); // Format - writeString(12, 'fmt '); // Subchunk1ID - view.setUint32(16, 16, true); // Subchunk1Size - view.setUint16(20, 1, true); // AudioFormat (PCM) - view.setUint16(22, numberOfChannels, true); // NumChannels - view.setUint32(24, sampleRate, true); // SampleRate - view.setUint32(28, byteRate, true); // ByteRate - view.setUint16(32, blockAlign, true); // BlockAlign - view.setUint16(34, 16, true); // BitsPerSample - writeString(36, 'data'); // Subchunk2ID - view.setUint32(40, dataSize, true); // Subchunk2Size - - // Cache channel arrays, write PCM via Int16Array (native little-endian, matches WAV) - const channels: Float32Array[] = new Array(numberOfChannels); - for (let c = 0; c < numberOfChannels; c++) { - channels[c] = buffer.getChannelData(c); - } - - const pcm = new Int16Array(arrayBuffer, 44, length * numberOfChannels); - let p = 0; - for (let i = 0; i < length; i++) { - for (let c = 0; c < numberOfChannels; c++) { - let s = channels[c][i]; - if (s > 1) s = 1; - else if (s < -1) s = -1; - pcm[p++] = s * 0x7fff; - } - } - - return new Blob([arrayBuffer], { type: MimeTypeAudio.WAV }); -} - -/** - * Create a File object from audio blob with timestamp-based naming - * @param audioBlob - The audio blob to wrap - * @param filename - Optional custom filename - * @returns File object with appropriate name and metadata - */ -export function createAudioFile(audioBlob: Blob, filename?: string): File { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const extension = audioBlob.type.includes('wav') ? 'wav' : 'mp3'; - const defaultFilename = `recording-${timestamp}.${extension}`; - - return new File([audioBlob], filename || defaultFilename, { - type: audioBlob.type, - lastModified: Date.now() - }); -} - -/** - * Check if audio recording is supported in the current browser - * @returns True if MediaRecorder and getUserMedia are available - */ -export function isAudioRecordingSupported(): boolean { - return !!( - typeof navigator !== 'undefined' && - navigator.mediaDevices && - typeof navigator.mediaDevices.getUserMedia === 'function' && - typeof window !== 'undefined' && - window.MediaRecorder - ); -} diff --git a/tools/server/webui/src/lib/utils/autoresize-textarea.ts b/tools/server/webui/src/lib/utils/autoresize-textarea.ts deleted file mode 100644 index cfee5ec15..000000000 --- a/tools/server/webui/src/lib/utils/autoresize-textarea.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Automatically resizes a textarea element to fit its content - * @param textareaElement - The textarea element to resize - */ -export default function autoResizeTextarea(textareaElement: HTMLTextAreaElement | null): void { - if (textareaElement) { - textareaElement.style.height = '1rem'; - textareaElement.style.height = textareaElement.scrollHeight + 'px'; - } -} diff --git a/tools/server/webui/src/lib/utils/branching.ts b/tools/server/webui/src/lib/utils/branching.ts deleted file mode 100644 index 4e117b3c2..000000000 --- a/tools/server/webui/src/lib/utils/branching.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Message branching utilities for conversation tree navigation. - * - * Conversation branching allows users to edit messages and create alternate paths - * while preserving the original conversation flow. Each message has parent/children - * relationships forming a tree structure. - * - * Example tree: - * root - * ├── message 1 (user) - * │ └── message 2 (assistant) - * │ ├── message 3 (user) - * │ └── message 6 (user) ← new branch - * └── message 4 (user) - * └── message 5 (assistant) - */ - -import { MessageRole } from '$lib/enums'; - -/** - * Finds a message by its ID in the given messages array. - */ -export function findMessageById( - messages: readonly DatabaseMessage[], - id: string | null | undefined -): DatabaseMessage | undefined { - if (!id) return undefined; - return messages.find((m) => m.id === id); -} - -/** - * Filters messages to get the conversation path from root to a specific leaf node. - * If the leafNodeId doesn't exist, returns the path with the latest timestamp. - * - * @param messages - All messages in the conversation - * @param leafNodeId - The target leaf node ID to trace back from - * @param includeRoot - Whether to include root messages in the result - * @returns Array of messages from root to leaf, sorted by timestamp - */ -export function filterByLeafNodeId( - messages: readonly DatabaseMessage[], - leafNodeId: string, - includeRoot: boolean = false -): readonly DatabaseMessage[] { - const result: DatabaseMessage[] = []; - const nodeMap = new Map(); - - // Build node map for quick lookups - for (const msg of messages) { - nodeMap.set(msg.id, msg); - } - - // Find the starting node (leaf node or latest if not found) - let startNode: DatabaseMessage | undefined = nodeMap.get(leafNodeId); - if (!startNode) { - // If leaf node not found, use the message with latest timestamp - let latestTime = -1; - for (const msg of messages) { - if (msg.timestamp > latestTime) { - startNode = msg; - latestTime = msg.timestamp; - } - } - } - - // Traverse from leaf to root, collecting messages - let currentNode: DatabaseMessage | undefined = startNode; - while (currentNode) { - // Include message if it's not root, or if we want to include root - if (currentNode.type !== 'root' || includeRoot) { - result.push(currentNode); - } - - // Stop traversal if parent is null (reached root) - if (currentNode.parent === null) { - break; - } - currentNode = nodeMap.get(currentNode.parent); - } - - // Sort: system messages first, then by timestamp - result.sort((a, b) => { - if (a.role === MessageRole.SYSTEM && b.role !== MessageRole.SYSTEM) return -1; - if (a.role !== MessageRole.SYSTEM && b.role === MessageRole.SYSTEM) return 1; - - return a.timestamp - b.timestamp; - }); - return result; -} - -/** - * Finds the leaf node (message with no children) for a given message branch. - * Traverses down the tree following the last child until reaching a leaf. - * - * @param messages - All messages in the conversation - * @param messageId - Starting message ID to find leaf for - * @returns The leaf node ID, or the original messageId if no children - */ -export function findLeafNode(messages: readonly DatabaseMessage[], messageId: string): string { - const nodeMap = new Map(); - - // Build node map for quick lookups - for (const msg of messages) { - nodeMap.set(msg.id, msg); - } - - let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId); - while (currentNode && currentNode.children.length > 0) { - // Follow the last child (most recent branch) - const lastChildId = currentNode.children[currentNode.children.length - 1]; - currentNode = nodeMap.get(lastChildId); - } - - return currentNode?.id ?? messageId; -} - -/** - * Finds all descendant messages (children, grandchildren, etc.) of a given message. - * This is used for cascading deletion to remove all messages in a branch. - * - * @param messages - All messages in the conversation - * @param messageId - The root message ID to find descendants for - * @returns Array of all descendant message IDs - */ -export function findDescendantMessages( - messages: readonly DatabaseMessage[], - messageId: string -): string[] { - const nodeMap = new Map(); - - // Build node map for quick lookups - for (const msg of messages) { - nodeMap.set(msg.id, msg); - } - - const descendants: string[] = []; - const queue: string[] = [messageId]; - - while (queue.length > 0) { - const currentId = queue.shift()!; - const currentNode = nodeMap.get(currentId); - - if (currentNode) { - // Add all children to the queue and descendants list - for (const childId of currentNode.children) { - descendants.push(childId); - queue.push(childId); - } - } - } - - return descendants; -} - -/** - * Gets sibling information for a message, including all sibling IDs and current position. - * Siblings are messages that share the same parent. - * - * @param messages - All messages in the conversation - * @param messageId - The message to get sibling info for - * @returns Sibling information including leaf node IDs for navigation - */ -export function getMessageSiblings( - messages: readonly DatabaseMessage[], - messageId: string -): ChatMessageSiblingInfo | null { - const nodeMap = new Map(); - - // Build node map for quick lookups - for (const msg of messages) { - nodeMap.set(msg.id, msg); - } - - const message = nodeMap.get(messageId); - if (!message) { - return null; - } - - // Handle null parent (root message) case - if (message.parent === null) { - // No parent means this is likely a root node with no siblings - return { - message, - siblingIds: [messageId], - currentIndex: 0, - totalSiblings: 1 - }; - } - - const parentNode = nodeMap.get(message.parent); - if (!parentNode) { - // Parent not found - treat as single message - return { - message, - siblingIds: [messageId], - currentIndex: 0, - totalSiblings: 1 - }; - } - - // Get all sibling IDs (including self) - const siblingIds = parentNode.children; - - // Convert sibling message IDs to their corresponding leaf node IDs - // This allows navigation between different conversation branches - const siblingLeafIds = siblingIds.map((siblingId: string) => findLeafNode(messages, siblingId)); - - // Find current message's position among siblings - const currentIndex = siblingIds.indexOf(messageId); - - return { - message, - siblingIds: siblingLeafIds, - currentIndex, - totalSiblings: siblingIds.length - }; -} - -/** - * Creates a display-ready list of messages with sibling information for UI rendering. - * This is the main function used by chat components to render conversation branches. - * - * @param messages - All messages in the conversation - * @param leafNodeId - Current leaf node being viewed - * @returns Array of messages with sibling navigation info - */ -export function getMessageDisplayList( - messages: readonly DatabaseMessage[], - leafNodeId: string -): ChatMessageSiblingInfo[] { - // Get the current conversation path - const currentPath = filterByLeafNodeId(messages, leafNodeId, true); - const result: ChatMessageSiblingInfo[] = []; - - // Add sibling info for each message in the current path - for (const message of currentPath) { - if (message.type === 'root') { - continue; // Skip root messages in display - } - - const siblingInfo = getMessageSiblings(messages, message.id); - if (siblingInfo) { - result.push(siblingInfo); - } - } - - return result; -} - -/** - * Checks if a message has multiple siblings (indicating branching at that point). - * - * @param messages - All messages in the conversation - * @param messageId - The message to check - * @returns True if the message has siblings - */ -export function hasMessageSiblings( - messages: readonly DatabaseMessage[], - messageId: string -): boolean { - const siblingInfo = getMessageSiblings(messages, messageId); - return siblingInfo ? siblingInfo.totalSiblings > 1 : false; -} - -/** - * Gets the next sibling message ID for navigation. - * - * @param messages - All messages in the conversation - * @param messageId - Current message ID - * @returns Next sibling's leaf node ID, or null if at the end - */ -export function getNextSibling( - messages: readonly DatabaseMessage[], - messageId: string -): string | null { - const siblingInfo = getMessageSiblings(messages, messageId); - if (!siblingInfo || siblingInfo.currentIndex >= siblingInfo.totalSiblings - 1) { - return null; - } - - return siblingInfo.siblingIds[siblingInfo.currentIndex + 1]; -} - -/** - * Gets the previous sibling message ID for navigation. - * - * @param messages - All messages in the conversation - * @param messageId - Current message ID - * @returns Previous sibling's leaf node ID, or null if at the beginning - */ -export function getPreviousSibling( - messages: readonly DatabaseMessage[], - messageId: string -): string | null { - const siblingInfo = getMessageSiblings(messages, messageId); - if (!siblingInfo || siblingInfo.currentIndex <= 0) { - return null; - } - - return siblingInfo.siblingIds[siblingInfo.currentIndex - 1]; -} diff --git a/tools/server/webui/src/lib/utils/browser-only.ts b/tools/server/webui/src/lib/utils/browser-only.ts deleted file mode 100644 index 27d2be4aa..000000000 --- a/tools/server/webui/src/lib/utils/browser-only.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Browser-only utility exports - * - * These utilities require browser APIs (DOM, Canvas, MediaRecorder, etc.) - * and cannot be imported during SSR. Import from '$lib/utils/browser-only' - * only in client-side code or components that are not server-rendered. - */ - -// Audio utilities (MediaRecorder API) -export { - AudioRecorder, - convertToWav, - createAudioFile, - isAudioRecordingSupported -} from './audio-recording'; - -// PDF processing utilities (pdfjs-dist with DOMMatrix) -export { - convertPDFToText, - convertPDFToImage, - isPdfFile as isPdfFileFromFile, - isApplicationMimeType -} from './pdf-processing'; - -// File conversion utilities (depends on pdf-processing) -export { parseFilesToMessageExtras } from './convert-files-to-extra'; - -// File upload processing utilities (depends on pdf-processing, svg-to-png, webp-to-png) -export { processFilesToChatUploaded } from './process-uploaded-files'; - -// SVG utilities (Canvas/Image API) -export { svgBase64UrlToPngDataURL, isSvgFile, isSvgMimeType } from './svg-to-png'; - -// WebP utilities (Canvas/Image API) -export { webpBase64UrlToPngDataURL, isWebpFile, isWebpMimeType } from './webp-to-png'; diff --git a/tools/server/webui/src/lib/utils/cache-ttl.ts b/tools/server/webui/src/lib/utils/cache-ttl.ts deleted file mode 100644 index 4e414dd54..000000000 --- a/tools/server/webui/src/lib/utils/cache-ttl.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_MAX_ENTRIES } from '$lib/constants'; - -/** - * TTL Cache - Time-To-Live cache implementation for memory optimization - * - * Provides automatic expiration of cached entries to prevent memory bloat - * in long-running sessions. - * - * @example - * ```ts - * const cache = new TTLCache({ ttlMs: 5 * 60 * 1000 }); // 5 minutes - * cache.set('key', data); - * const value = cache.get('key'); // null if expired - * ``` - */ - -export interface TTLCacheOptions { - /** Time-to-live in milliseconds. Default: 5 minutes */ - ttlMs?: number; - /** Maximum number of entries. Oldest entries are evicted when exceeded. Default: 100 */ - maxEntries?: number; - /** Callback when an entry expires or is evicted */ - onEvict?: (key: string, value: unknown) => void; -} - -interface CacheEntry { - value: T; - expiresAt: number; - lastAccessed: number; -} - -export class TTLCache { - private cache = new Map>(); - private readonly ttlMs: number; - private readonly maxEntries: number; - private readonly onEvict?: (key: string, value: unknown) => void; - - constructor(options: TTLCacheOptions = {}) { - this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS; - this.maxEntries = options.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES; - this.onEvict = options.onEvict; - } - - /** - * Get a value from cache. Returns null if expired or not found. - */ - get(key: K): V | null { - const entry = this.cache.get(key); - if (!entry) return null; - - if (Date.now() > entry.expiresAt) { - this.delete(key); - return null; - } - - // Update last accessed time for LRU-like behavior - entry.lastAccessed = Date.now(); - return entry.value; - } - - /** - * Set a value in cache with TTL. - */ - set(key: K, value: V, customTtlMs?: number): void { - // Evict oldest entries if at capacity - if (this.cache.size >= this.maxEntries && !this.cache.has(key)) { - this.evictOldest(); - } - - const ttl = customTtlMs ?? this.ttlMs; - const now = Date.now(); - - this.cache.set(key, { - value, - expiresAt: now + ttl, - lastAccessed: now - }); - } - - /** - * Check if key exists and is not expired. - */ - has(key: K): boolean { - const entry = this.cache.get(key); - if (!entry) return false; - - if (Date.now() > entry.expiresAt) { - this.delete(key); - return false; - } - - return true; - } - - /** - * Delete a specific key from cache. - */ - delete(key: K): boolean { - const entry = this.cache.get(key); - if (entry && this.onEvict) { - this.onEvict(key, entry.value); - } - return this.cache.delete(key); - } - - /** - * Clear all entries from cache. - */ - clear(): void { - if (this.onEvict) { - for (const [key, entry] of this.cache) { - this.onEvict(key, entry.value); - } - } - this.cache.clear(); - } - - /** - * Get the number of entries (including potentially expired ones). - */ - get size(): number { - return this.cache.size; - } - - /** - * Remove all expired entries from cache. - * Call periodically for proactive cleanup. - */ - prune(): number { - const now = Date.now(); - let pruned = 0; - - for (const [key, entry] of this.cache) { - if (now > entry.expiresAt) { - this.delete(key); - pruned++; - } - } - - return pruned; - } - - /** - * Get all valid (non-expired) keys. - */ - keys(): K[] { - const now = Date.now(); - const validKeys: K[] = []; - - for (const [key, entry] of this.cache) { - if (now <= entry.expiresAt) { - validKeys.push(key); - } - } - - return validKeys; - } - - /** - * Evict the oldest (least recently accessed) entry. - */ - private evictOldest(): void { - let oldestKey: K | null = null; - let oldestTime = Infinity; - - for (const [key, entry] of this.cache) { - if (entry.lastAccessed < oldestTime) { - oldestTime = entry.lastAccessed; - oldestKey = key; - } - } - - if (oldestKey !== null) { - this.delete(oldestKey); - } - } - - /** - * Refresh TTL for an existing entry without changing the value. - */ - touch(key: K): boolean { - const entry = this.cache.get(key); - if (!entry) return false; - - const now = Date.now(); - if (now > entry.expiresAt) { - this.delete(key); - return false; - } - - entry.expiresAt = now + this.ttlMs; - entry.lastAccessed = now; - return true; - } -} - -/** - * Reactive TTL Map for Svelte stores - * Wraps SvelteMap with TTL functionality - */ -export class ReactiveTTLMap { - private entries = $state>>(new Map()); - private readonly ttlMs: number; - private readonly maxEntries: number; - - constructor(options: TTLCacheOptions = {}) { - this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS; - this.maxEntries = options.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES; - } - - get(key: K): V | null { - const entry = this.entries.get(key); - if (!entry) return null; - - if (Date.now() > entry.expiresAt) { - this.entries.delete(key); - return null; - } - - entry.lastAccessed = Date.now(); - return entry.value; - } - - set(key: K, value: V, customTtlMs?: number): void { - if (this.entries.size >= this.maxEntries && !this.entries.has(key)) { - this.evictOldest(); - } - - const ttl = customTtlMs ?? this.ttlMs; - const now = Date.now(); - - this.entries.set(key, { - value, - expiresAt: now + ttl, - lastAccessed: now - }); - } - - has(key: K): boolean { - const entry = this.entries.get(key); - if (!entry) return false; - - if (Date.now() > entry.expiresAt) { - this.entries.delete(key); - return false; - } - - return true; - } - - delete(key: K): boolean { - return this.entries.delete(key); - } - - clear(): void { - this.entries.clear(); - } - - get size(): number { - return this.entries.size; - } - - prune(): number { - const now = Date.now(); - let pruned = 0; - - for (const [key, entry] of this.entries) { - if (now > entry.expiresAt) { - this.entries.delete(key); - pruned++; - } - } - - return pruned; - } - - private evictOldest(): void { - let oldestKey: K | null = null; - let oldestTime = Infinity; - - for (const [key, entry] of this.entries) { - if (entry.lastAccessed < oldestTime) { - oldestTime = entry.lastAccessed; - oldestKey = key; - } - } - - if (oldestKey !== null) { - this.entries.delete(oldestKey); - } - } -} diff --git a/tools/server/webui/src/lib/utils/clipboard.ts b/tools/server/webui/src/lib/utils/clipboard.ts deleted file mode 100644 index 8fcb554b1..000000000 --- a/tools/server/webui/src/lib/utils/clipboard.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { toast } from 'svelte-sonner'; -import { AttachmentType } from '$lib/enums'; -import type { - DatabaseMessageExtra, - DatabaseMessageExtraTextFile, - DatabaseMessageExtraLegacyContext, - DatabaseMessageExtraMcpPrompt, - DatabaseMessageExtraMcpResource, - ClipboardTextAttachment, - ClipboardMcpPromptAttachment, - ClipboardAttachment, - ParsedClipboardContent -} from '$lib/types'; - -/** - * Copy text to clipboard with toast notification - * Uses modern clipboard API when available, falls back to legacy method for non-secure contexts - * @param text - Text to copy to clipboard - * @param successMessage - Custom success message (optional) - * @param errorMessage - Custom error message (optional) - * @returns Promise - True if successful, false otherwise - */ -export async function copyToClipboard( - text: string, - successMessage = 'Copied to clipboard', - errorMessage = 'Failed to copy to clipboard' -): Promise { - try { - // Try modern clipboard API first (secure contexts only) - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - toast.success(successMessage); - return true; - } - - // Fallback for non-secure contexts - const textArea = document.createElement('textarea'); - textArea.value = text; - textArea.style.position = 'fixed'; - textArea.style.left = '-999999px'; - textArea.style.top = '-999999px'; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - const successful = document.execCommand('copy'); - document.body.removeChild(textArea); - - if (successful) { - toast.success(successMessage); - return true; - } else { - throw new Error('execCommand failed'); - } - } catch (error) { - console.error('Failed to copy to clipboard:', error); - toast.error(errorMessage); - return false; - } -} - -/** - * Copy code with HTML entity decoding and toast notification - * @param rawCode - Raw code string that may contain HTML entities - * @param successMessage - Custom success message (optional) - * @param errorMessage - Custom error message (optional) - * @returns Promise - True if successful, false otherwise - */ -export async function copyCodeToClipboard( - rawCode: string, - successMessage = 'Code copied to clipboard', - errorMessage = 'Failed to copy code' -): Promise { - return copyToClipboard(rawCode, successMessage, errorMessage); -} - -/** - * Formats a message with text attachments for clipboard copying. - * - * Default format (asPlainText = false): - * ``` - * "Text message content" - * [ - * {"type":"TEXT","name":"filename.txt","content":"..."}, - * {"type":"TEXT","name":"another.txt","content":"..."} - * ] - * ``` - * - * Plain text format (asPlainText = true): - * ``` - * Text message content - * - * file content here - * - * another file content - * ``` - * - * @param content - The message text content - * @param extras - Optional array of message attachments - * @param asPlainText - If true, format as plain text without JSON structure - * @returns Formatted string for clipboard - */ -export function formatMessageForClipboard( - content: string, - extras?: DatabaseMessageExtra[], - asPlainText: boolean = false -): string { - // Filter text-like attachments (TEXT, LEGACY_CONTEXT, MCP_PROMPT, and MCP_RESOURCE types) - const textAttachments = - extras?.filter( - ( - extra - ): extra is - | DatabaseMessageExtraTextFile - | DatabaseMessageExtraLegacyContext - | DatabaseMessageExtraMcpPrompt - | DatabaseMessageExtraMcpResource => - extra.type === AttachmentType.TEXT || - extra.type === AttachmentType.LEGACY_CONTEXT || - extra.type === AttachmentType.MCP_PROMPT || - extra.type === AttachmentType.MCP_RESOURCE - ) ?? []; - - if (textAttachments.length === 0) { - return content; - } - - if (asPlainText) { - const parts = [content]; - for (const att of textAttachments) { - parts.push(att.content); - } - return parts.join('\n\n'); - } - - const clipboardAttachments: ClipboardAttachment[] = textAttachments.map((att) => { - if (att.type === AttachmentType.MCP_PROMPT) { - const mcpAtt = att as DatabaseMessageExtraMcpPrompt; - return { - type: AttachmentType.MCP_PROMPT, - name: mcpAtt.name, - serverName: mcpAtt.serverName, - promptName: mcpAtt.promptName, - content: mcpAtt.content, - arguments: mcpAtt.arguments - } as ClipboardMcpPromptAttachment; - } - return { - type: AttachmentType.TEXT, - name: att.name, - content: att.content - } as ClipboardTextAttachment; - }); - - return `${JSON.stringify(content)}\n${JSON.stringify(clipboardAttachments, null, 2)}`; -} - -/** - * Parses clipboard content to extract message and text attachments. - * Supports both plain text and the special format with attachments. - * - * @param clipboardText - Raw text from clipboard - * @returns Parsed content with message and attachments - */ -export function parseClipboardContent(clipboardText: string): ParsedClipboardContent { - const defaultResult: ParsedClipboardContent = { - message: clipboardText, - textAttachments: [], - mcpPromptAttachments: [] - }; - - if (!clipboardText.startsWith('"')) { - return defaultResult; - } - - try { - let stringEndIndex = -1; - let escaped = false; - - for (let i = 1; i < clipboardText.length; i++) { - const char = clipboardText[i]; - - if (escaped) { - escaped = false; - continue; - } - - if (char === '\\') { - escaped = true; - continue; - } - - if (char === '"') { - stringEndIndex = i; - break; - } - } - - if (stringEndIndex === -1) { - return defaultResult; - } - - const jsonStringPart = clipboardText.substring(0, stringEndIndex + 1); - const remainingPart = clipboardText.substring(stringEndIndex + 1).trim(); - - const message = JSON.parse(jsonStringPart) as string; - - if (!remainingPart || !remainingPart.startsWith('[')) { - return { - message, - textAttachments: [], - mcpPromptAttachments: [] - }; - } - - const attachments = JSON.parse(remainingPart) as unknown[]; - - const validTextAttachments: ClipboardTextAttachment[] = []; - const validMcpPromptAttachments: ClipboardMcpPromptAttachment[] = []; - - for (const att of attachments) { - if (isValidMcpPromptAttachment(att)) { - validMcpPromptAttachments.push({ - type: AttachmentType.MCP_PROMPT, - name: att.name, - serverName: att.serverName, - promptName: att.promptName, - content: att.content, - arguments: att.arguments - }); - } else if (isValidTextAttachment(att)) { - validTextAttachments.push({ - type: AttachmentType.TEXT, - name: att.name, - content: att.content - }); - } - } - - return { - message, - textAttachments: validTextAttachments, - mcpPromptAttachments: validMcpPromptAttachments - }; - } catch { - return defaultResult; - } -} - -/** - * Type guard to validate an MCP prompt attachment object - * @param obj The object to validate - * @returns true if the object is a valid MCP prompt attachment - */ -function isValidMcpPromptAttachment(obj: unknown): obj is { - type: string; - name: string; - serverName: string; - promptName: string; - content: string; - arguments?: Record; -} { - if (typeof obj !== 'object' || obj === null) { - return false; - } - - const record = obj as Record; - - return ( - (record.type === AttachmentType.MCP_PROMPT || record.type === 'MCP_PROMPT') && - typeof record.name === 'string' && - typeof record.serverName === 'string' && - typeof record.promptName === 'string' && - typeof record.content === 'string' - ); -} - -/** - * Type guard to validate a text attachment object - * @param obj The object to validate - * @returns true if the object is a valid text attachment - */ -function isValidTextAttachment( - obj: unknown -): obj is { type: string; name: string; content: string } { - if (typeof obj !== 'object' || obj === null) { - return false; - } - - const record = obj as Record; - - return ( - (record.type === AttachmentType.TEXT || record.type === 'TEXT') && - typeof record.name === 'string' && - typeof record.content === 'string' - ); -} - -/** - * Checks if clipboard content contains our special format with attachments - * @param clipboardText - Raw text from clipboard - * @returns true if the clipboard content contains our special format with attachments - */ -export function hasClipboardAttachments(clipboardText: string): boolean { - if (!clipboardText.startsWith('"')) { - return false; - } - - const parsed = parseClipboardContent(clipboardText); - return parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0; -} diff --git a/tools/server/webui/src/lib/utils/code.ts b/tools/server/webui/src/lib/utils/code.ts deleted file mode 100644 index d83bc31af..000000000 --- a/tools/server/webui/src/lib/utils/code.ts +++ /dev/null @@ -1,85 +0,0 @@ -import hljs from 'highlight.js'; -import { - NEWLINE, - DEFAULT_LANGUAGE, - LANG_PATTERN, - AMPERSAND_REGEX, - LT_REGEX, - GT_REGEX, - FENCE_PATTERN -} from '$lib/constants'; - -export interface IncompleteCodeBlock { - language: string; - code: string; - openingIndex: number; -} - -/** - * Highlights code using highlight.js - * @param code - The code to highlight - * @param language - The programming language - * @returns HTML string with syntax highlighting - */ -export function highlightCode(code: string, language: string): string { - if (!code) return ''; - - try { - const lang = language.toLowerCase(); - const isSupported = hljs.getLanguage(lang); - - if (isSupported) { - return hljs.highlight(code, { language: lang }).value; - } else { - return hljs.highlightAuto(code).value; - } - } catch { - // Fallback to escaped plain text - return code - .replace(AMPERSAND_REGEX, '&') - .replace(LT_REGEX, '<') - .replace(GT_REGEX, '>'); - } -} - -/** - * Detects if markdown ends with an incomplete code block (opened but not closed). - * Returns the code block info if found, null otherwise. - * @param markdown - The raw markdown string to check - * @returns IncompleteCodeBlock info or null - */ -export function detectIncompleteCodeBlock(markdown: string): IncompleteCodeBlock | null { - // Count all code fences in the markdown - // A code block is incomplete if there's an odd number of ``` fences - const fencePattern = new RegExp(FENCE_PATTERN.source, FENCE_PATTERN.flags); - const fences: number[] = []; - let fenceMatch; - - while ((fenceMatch = fencePattern.exec(markdown)) !== null) { - // Store the position after the ``` - const pos = fenceMatch[0].startsWith(NEWLINE) ? fenceMatch.index + 1 : fenceMatch.index; - fences.push(pos); - } - - // If even number of fences (including 0), all code blocks are closed - if (fences.length % 2 === 0) { - return null; - } - - // Odd number means last code block is incomplete - // The last fence is the opening of the incomplete block - const openingIndex = fences[fences.length - 1]; - const afterOpening = markdown.slice(openingIndex + 3); - - // Extract language and code content - const langMatch = afterOpening.match(LANG_PATTERN); - const language = langMatch?.[1] || DEFAULT_LANGUAGE; - const codeStartIndex = openingIndex + 3 + (langMatch?.[0]?.length ?? 0); - const code = markdown.slice(codeStartIndex); - - return { - language, - code, - openingIndex - }; -} diff --git a/tools/server/webui/src/lib/utils/config-helpers.ts b/tools/server/webui/src/lib/utils/config-helpers.ts deleted file mode 100644 index b85242d85..000000000 --- a/tools/server/webui/src/lib/utils/config-helpers.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Type-safe configuration helpers - * - * Provides utilities for safely accessing and modifying configuration objects - * with dynamic keys while maintaining TypeScript type safety. - */ - -/** - * Type-safe helper to access config properties dynamically - * Provides better type safety than direct casting to Record - */ -export function setConfigValue( - config: T, - key: string, - value: unknown -): void { - if (key in config) { - (config as Record)[key] = value; - } -} - -/** - * Type-safe helper to get config values dynamically - */ -export function getConfigValue( - config: T, - key: string -): string | number | boolean | undefined { - const value = (config as Record)[key]; - return value as string | number | boolean | undefined; -} - -/** - * Convert a SettingsConfigType to a ParameterRecord for specific keys - * Useful for parameter synchronization operations - */ -export function configToParameterRecord( - config: T, - keys: string[] -): Record { - const record: Record = {}; - - for (const key of keys) { - const value = getConfigValue(config, key); - if (value !== undefined) { - record[key] = value; - } - } - - return record; -} diff --git a/tools/server/webui/src/lib/utils/conversation-utils.ts b/tools/server/webui/src/lib/utils/conversation-utils.ts deleted file mode 100644 index 2c3d83899..000000000 --- a/tools/server/webui/src/lib/utils/conversation-utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Utility functions for conversation data manipulation - */ -import type { DatabaseMessage } from '$lib/types'; - -/** - * Creates a map of conversation IDs to their message counts from exported conversation data - * @param exportedData - Array of exported conversations with their messages - * @returns Map of conversation ID to message count - */ -export function createMessageCountMap( - exportedData: Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }> -): Map { - const countMap = new Map(); - - for (const item of exportedData) { - countMap.set(item.conv.id, item.messages.length); - } - - return countMap; -} - -/** - * Gets the message count for a specific conversation from the count map - * @param conversationId - The ID of the conversation - * @param countMap - Map of conversation IDs to message counts - * @returns The message count, or 0 if not found - */ -export function getMessageCount(conversationId: string, countMap: Map): number { - return countMap.get(conversationId) ?? 0; -} diff --git a/tools/server/webui/src/lib/utils/convert-files-to-extra.ts b/tools/server/webui/src/lib/utils/convert-files-to-extra.ts deleted file mode 100644 index 12be45485..000000000 --- a/tools/server/webui/src/lib/utils/convert-files-to-extra.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { convertPDFToImage, convertPDFToText } from './pdf-processing'; -import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png'; -import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png'; -import { FileTypeCategory, AttachmentType, SpecialFileType } from '$lib/enums'; -import { SETTINGS_KEYS } from '$lib/constants'; -import { config, settingsStore } from '$lib/stores/settings.svelte'; -import { modelsStore } from '$lib/stores/models.svelte'; -import { getFileTypeCategory } from '$lib/utils'; -import { readFileAsText, isLikelyTextFile } from './text-files'; -import { toast } from 'svelte-sonner'; -import type { FileProcessingResult, ChatUploadedFile, DatabaseMessageExtra } from '$lib/types'; - -function readFileAsBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => { - // Extract base64 data without the data URL prefix - const dataUrl = reader.result as string; - const base64 = dataUrl.split(',')[1]; - resolve(base64); - }; - - reader.onerror = () => reject(reader.error); - - reader.readAsDataURL(file); - }); -} - -export async function parseFilesToMessageExtras( - files: ChatUploadedFile[], - activeModelId?: string -): Promise { - const extras: DatabaseMessageExtra[] = []; - const emptyFiles: string[] = []; - - for (const file of files) { - if (file.type === SpecialFileType.MCP_PROMPT && file.mcpPrompt) { - extras.push({ - type: AttachmentType.MCP_PROMPT, - name: file.name, - size: file.size, - serverName: file.mcpPrompt.serverName, - promptName: file.mcpPrompt.promptName, - content: file.textContent ?? '', - arguments: file.mcpPrompt.arguments - }); - - continue; - } - - if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) { - if (file.preview) { - let base64Url = file.preview; - - if (isSvgMimeType(file.type)) { - try { - base64Url = await svgBase64UrlToPngDataURL(base64Url); - } catch (error) { - console.error('Failed to convert SVG to PNG for database storage:', error); - } - } else if (isWebpMimeType(file.type)) { - try { - base64Url = await webpBase64UrlToPngDataURL(base64Url); - } catch (error) { - console.error('Failed to convert WebP to PNG for database storage:', error); - } - } - - extras.push({ - type: AttachmentType.IMAGE, - name: file.name, - size: file.size, - base64Url - }); - } - } else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) { - // Process audio files (MP3 and WAV) - try { - const base64Data = await readFileAsBase64(file.file); - - extras.push({ - type: AttachmentType.AUDIO, - name: file.name, - size: file.size, - base64Data: base64Data, - mimeType: file.type - }); - } catch (error) { - console.error(`Failed to process audio file ${file.name}:`, error); - } - } else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) { - try { - // Always get base64 data for preview functionality - const base64Data = await readFileAsBase64(file.file); - const currentConfig = config(); - // Use per-model vision check for router mode - const hasVisionSupport = activeModelId - ? modelsStore.modelSupportsVision(activeModelId) - : false; - - // Force PDF-to-text for non-vision models - let shouldProcessAsImages = Boolean(currentConfig.pdfAsImage) && hasVisionSupport; - - // If user had pdfAsImage enabled but model doesn't support vision, update setting and notify - if (currentConfig.pdfAsImage && !hasVisionSupport) { - console.log('Non-vision model detected: forcing PDF-to-text mode and updating settings'); - - // Update the setting in localStorage - settingsStore.updateConfig(SETTINGS_KEYS.PDF_AS_IMAGE, false); - - // Show toast notification to user - toast.warning( - 'PDF setting changed: Non-vision model detected, PDFs will be processed as text instead of images.', - { - duration: 5000 - } - ); - - shouldProcessAsImages = false; - } - - if (shouldProcessAsImages) { - // Process PDF as images (only for vision models) - try { - const images = await convertPDFToImage(file.file); - - // Show success toast for PDF image processing - toast.success( - `PDF "${file.name}" processed as ${images.length} images for vision model.`, - { - duration: 3000 - } - ); - - extras.push({ - type: AttachmentType.PDF, - name: file.name, - size: file.size, - content: `PDF file with ${images.length} pages`, - images: images, - processedAsImages: true, - base64Data: base64Data - }); - } catch (imageError) { - console.warn( - `Failed to process PDF ${file.name} as images, falling back to text:`, - imageError - ); - - // Fallback to text processing - const content = await convertPDFToText(file.file); - - extras.push({ - type: AttachmentType.PDF, - name: file.name, - size: file.size, - content: content, - processedAsImages: false, - base64Data: base64Data - }); - } - } else { - // Process PDF as text (default or forced for non-vision models) - const content = await convertPDFToText(file.file); - - // Show success toast for PDF text processing - toast.success(`PDF "${file.name}" processed as text content.`, { - duration: 3000 - }); - - extras.push({ - type: AttachmentType.PDF, - name: file.name, - size: file.size, - content: content, - processedAsImages: false, - base64Data: base64Data - }); - } - } catch (error) { - console.error(`Failed to process PDF file ${file.name}:`, error); - } - } else { - try { - const content = await readFileAsText(file.file); - - // Check if file is empty - if (content.trim() === '') { - console.warn(`File ${file.name} is empty and will be skipped`); - emptyFiles.push(file.name); - } else if (isLikelyTextFile(content)) { - extras.push({ - type: AttachmentType.TEXT, - name: file.name, - size: file.size, - content: content - }); - } else { - console.warn(`File ${file.name} appears to be binary and will be skipped`); - } - } catch (error) { - console.error(`Failed to read file ${file.name}:`, error); - } - } - } - - return { extras, emptyFiles }; -} diff --git a/tools/server/webui/src/lib/utils/cors-proxy.ts b/tools/server/webui/src/lib/utils/cors-proxy.ts deleted file mode 100644 index 47caf2742..000000000 --- a/tools/server/webui/src/lib/utils/cors-proxy.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * CORS Proxy utility for routing requests through llama-server's CORS proxy. - */ - -import { base } from '$app/paths'; -import { CORS_PROXY_ENDPOINT, CORS_PROXY_URL_PARAM } from '$lib/constants'; - -/** - * Build a proxied URL that routes through llama-server's CORS proxy. - * @param targetUrl - The original URL to proxy - * @returns URL pointing to the CORS proxy with target encoded - */ -export function buildProxiedUrl(targetUrl: string): URL { - const proxyPath = `${base}${CORS_PROXY_ENDPOINT}`; - const proxyUrl = new URL(proxyPath, window.location.origin); - - proxyUrl.searchParams.set(CORS_PROXY_URL_PARAM, targetUrl); - - return proxyUrl; -} - -/** - * Wrap original headers for proxying through the CORS proxy. This avoids issues with duplicated llama.cpp-specific and target headers when using the CORS proxy. - * @param headers - The original headers to be proxied to target - * @returns List of "wrapped" headers to be sent to the CORS proxy - */ -export function buildProxiedHeaders(headers: Record): Record { - const proxiedHeaders: Record = {}; - - for (const [key, value] of Object.entries(headers)) { - proxiedHeaders[`x-proxy-header-${key}`] = value; - } - - return proxiedHeaders; -} diff --git a/tools/server/webui/src/lib/utils/css.ts b/tools/server/webui/src/lib/utils/css.ts deleted file mode 100644 index 99351a7f4..000000000 --- a/tools/server/webui/src/lib/utils/css.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Converts a rem CSS value to pixels based on the document root font size. - */ -export function remToPx(rem: string): number { - const val = parseFloat(rem); - const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); - - return val * fontSize; -} diff --git a/tools/server/webui/src/lib/utils/data-url.ts b/tools/server/webui/src/lib/utils/data-url.ts deleted file mode 100644 index 6f55be793..000000000 --- a/tools/server/webui/src/lib/utils/data-url.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Creates a base64 data URL from MIME type and base64-encoded data. - * - * @param mimeType - The MIME type (e.g., 'image/png', 'audio/mp3') - * @param base64Data - The base64-encoded data - * @returns A data URL string in format 'data:{mimeType};base64,{data}' - */ -export function createBase64DataUrl(mimeType: string, base64Data: string): string { - return `data:${mimeType};base64,${base64Data}`; -} diff --git a/tools/server/webui/src/lib/utils/debounce.ts b/tools/server/webui/src/lib/utils/debounce.ts deleted file mode 100644 index 90a5a0178..000000000 --- a/tools/server/webui/src/lib/utils/debounce.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @param fn - The function to debounce - * @param delay - The delay in milliseconds - * @returns A debounced version of the function - */ -export function debounce) => void>( - fn: T, - delay: number -): (...args: Parameters) => void { - let timeoutId: ReturnType | null = null; - - return (...args: Parameters) => { - if (timeoutId) { - clearTimeout(timeoutId); - } - - timeoutId = setTimeout(() => { - fn(...args); - timeoutId = null; - }, delay); - }; -} diff --git a/tools/server/webui/src/lib/utils/file-preview.ts b/tools/server/webui/src/lib/utils/file-preview.ts deleted file mode 100644 index 26a60533a..000000000 --- a/tools/server/webui/src/lib/utils/file-preview.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Gets a display label for a file type from various input formats - * - * Handles: - * - MIME types: 'application/pdf' → 'PDF' - * - AttachmentType values: 'PDF', 'AUDIO' → 'PDF', 'AUDIO' - * - File names: 'document.pdf' → 'PDF' - * - Unknown: returns 'FILE' - * - * @param input - MIME type, AttachmentType value, or file name - * @returns Formatted file type label (uppercase) - */ -export function getFileTypeLabel(input: string | undefined): string { - if (!input) return 'FILE'; - - // Handle MIME types (contains '/') - if (input.includes('/')) { - const subtype = input.split('/').pop(); - if (subtype) { - // Handle special cases like 'vnd.ms-excel' → 'EXCEL' - if (subtype.includes('.')) { - return subtype.split('.').pop()?.toUpperCase() || 'FILE'; - } - return subtype.toUpperCase(); - } - } - - // Handle file names (contains '.') - if (input.includes('.')) { - const ext = input.split('.').pop(); - if (ext) return ext.toUpperCase(); - } - - // Handle AttachmentType or other plain strings - return input.toUpperCase(); -} diff --git a/tools/server/webui/src/lib/utils/file-type.ts b/tools/server/webui/src/lib/utils/file-type.ts deleted file mode 100644 index 4c670600c..000000000 --- a/tools/server/webui/src/lib/utils/file-type.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { - AUDIO_FILE_TYPES, - IMAGE_FILE_TYPES, - PDF_FILE_TYPES, - TEXT_FILE_TYPES -} from '$lib/constants'; -import { - FileExtensionAudio, - FileExtensionImage, - FileExtensionPdf, - FileExtensionText, - FileTypeCategory, - MimeTypeApplication, - MimeTypeAudio, - MimeTypeImage, - MimeTypeText -} from '$lib/enums'; - -export function getFileTypeCategory(mimeType: string): FileTypeCategory | null { - switch (mimeType) { - // Images - case MimeTypeImage.JPEG: - case MimeTypeImage.PNG: - case MimeTypeImage.GIF: - case MimeTypeImage.WEBP: - case MimeTypeImage.SVG: - return FileTypeCategory.IMAGE; - - // Audio - case MimeTypeAudio.MP3_MPEG: - case MimeTypeAudio.MP3: - case MimeTypeAudio.MP4: - case MimeTypeAudio.WAV: - case MimeTypeAudio.WEBM: - case MimeTypeAudio.WEBM_OPUS: - return FileTypeCategory.AUDIO; - - // PDF - case MimeTypeApplication.PDF: - return FileTypeCategory.PDF; - - // Text - case MimeTypeText.PLAIN: - case MimeTypeText.MARKDOWN: - case MimeTypeText.ASCIIDOC: - case MimeTypeText.JAVASCRIPT: - case MimeTypeText.JAVASCRIPT_APP: - case MimeTypeText.TYPESCRIPT: - case MimeTypeText.JSX: - case MimeTypeText.TSX: - case MimeTypeText.CSS: - case MimeTypeText.HTML: - case MimeTypeText.JSON: - case MimeTypeText.XML_TEXT: - case MimeTypeText.XML_APP: - case MimeTypeText.YAML_TEXT: - case MimeTypeText.YAML_APP: - case MimeTypeText.CSV: - case MimeTypeText.PYTHON: - case MimeTypeText.JAVA: - case MimeTypeText.CPP_SRC: - case MimeTypeText.C_SRC: - case MimeTypeText.C_HDR: - case MimeTypeText.PHP: - case MimeTypeText.RUBY: - case MimeTypeText.GO: - case MimeTypeText.RUST: - case MimeTypeText.SHELL: - case MimeTypeText.BAT: - case MimeTypeText.SQL: - case MimeTypeText.R: - case MimeTypeText.SCALA: - case MimeTypeText.KOTLIN: - case MimeTypeText.SWIFT: - case MimeTypeText.DART: - case MimeTypeText.VUE: - case MimeTypeText.SVELTE: - case MimeTypeText.LATEX: - case MimeTypeText.BIBTEX: - case MimeTypeText.CUDA: - case MimeTypeText.CPP_HDR: - case MimeTypeText.CSHARP: - case MimeTypeText.HASKELL: - case MimeTypeText.PROPERTIES: - case MimeTypeText.TEX: - case MimeTypeText.TEX_APP: - return FileTypeCategory.TEXT; - - default: - return null; - } -} - -export function getFileTypeCategoryByExtension(filename: string): FileTypeCategory | null { - const extension = filename.toLowerCase().substring(filename.lastIndexOf('.')); - - switch (extension) { - // Images - case FileExtensionImage.JPG: - case FileExtensionImage.JPEG: - case FileExtensionImage.PNG: - case FileExtensionImage.GIF: - case FileExtensionImage.WEBP: - case FileExtensionImage.SVG: - return FileTypeCategory.IMAGE; - - // Audio - case FileExtensionAudio.MP3: - case FileExtensionAudio.WAV: - return FileTypeCategory.AUDIO; - - // PDF - case FileExtensionPdf.PDF: - return FileTypeCategory.PDF; - - // Text - case FileExtensionText.TXT: - case FileExtensionText.MD: - case FileExtensionText.ADOC: - case FileExtensionText.JS: - case FileExtensionText.TS: - case FileExtensionText.JSX: - case FileExtensionText.TSX: - case FileExtensionText.CSS: - case FileExtensionText.HTML: - case FileExtensionText.HTM: - case FileExtensionText.JSON: - case FileExtensionText.XML: - case FileExtensionText.YAML: - case FileExtensionText.YML: - case FileExtensionText.CSV: - case FileExtensionText.LOG: - case FileExtensionText.PY: - case FileExtensionText.JAVA: - case FileExtensionText.CPP: - case FileExtensionText.C: - case FileExtensionText.H: - case FileExtensionText.PHP: - case FileExtensionText.RB: - case FileExtensionText.GO: - case FileExtensionText.RS: - case FileExtensionText.SH: - case FileExtensionText.BAT: - case FileExtensionText.SQL: - case FileExtensionText.R: - case FileExtensionText.SCALA: - case FileExtensionText.KT: - case FileExtensionText.SWIFT: - case FileExtensionText.DART: - case FileExtensionText.VUE: - case FileExtensionText.SVELTE: - case FileExtensionText.TEX: - case FileExtensionText.BIB: - case FileExtensionText.COMP: - case FileExtensionText.CU: - case FileExtensionText.CUH: - case FileExtensionText.HPP: - case FileExtensionText.HS: - case FileExtensionText.PROPERTIES: - return FileTypeCategory.TEXT; - - default: - return null; - } -} - -export function getFileTypeByExtension(filename: string): string | null { - const extension = filename.toLowerCase().substring(filename.lastIndexOf('.')); - - for (const [key, type] of Object.entries(IMAGE_FILE_TYPES)) { - if ((type.extensions as readonly string[]).includes(extension)) { - return `${FileTypeCategory.IMAGE}:${key}`; - } - } - - for (const [key, type] of Object.entries(AUDIO_FILE_TYPES)) { - if ((type.extensions as readonly string[]).includes(extension)) { - return `${FileTypeCategory.AUDIO}:${key}`; - } - } - - for (const [key, type] of Object.entries(PDF_FILE_TYPES)) { - if ((type.extensions as readonly string[]).includes(extension)) { - return `${FileTypeCategory.PDF}:${key}`; - } - } - - for (const [key, type] of Object.entries(TEXT_FILE_TYPES)) { - if ((type.extensions as readonly string[]).includes(extension)) { - return `${FileTypeCategory.TEXT}:${key}`; - } - } - - return null; -} - -export function isFileTypeSupported(filename: string, mimeType?: string): boolean { - // Images are detected and handled separately for vision models - if (mimeType) { - const category = getFileTypeCategory(mimeType); - if ( - category === FileTypeCategory.IMAGE || - category === FileTypeCategory.AUDIO || - category === FileTypeCategory.PDF - ) { - return true; - } - } - - // Check extension for known types (especially images without MIME) - const extCategory = getFileTypeCategoryByExtension(filename); - if ( - extCategory === FileTypeCategory.IMAGE || - extCategory === FileTypeCategory.AUDIO || - extCategory === FileTypeCategory.PDF - ) { - return true; - } - - // Fallback: treat everything else as text (inclusive by default) - return true; -} diff --git a/tools/server/webui/src/lib/utils/formatters.ts b/tools/server/webui/src/lib/utils/formatters.ts deleted file mode 100644 index 24a2c1c94..000000000 --- a/tools/server/webui/src/lib/utils/formatters.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { - MS_PER_SECOND, - SECONDS_PER_MINUTE, - SECONDS_PER_HOUR, - SHORT_DURATION_THRESHOLD, - MEDIUM_DURATION_THRESHOLD -} from '$lib/constants'; - -/** - * Formats file size in bytes to human readable format - * Supports Bytes, KB, MB, and GB - * - * @param bytes - File size in bytes (or unknown for safety) - * @returns Formatted file size string - */ -export function formatFileSize(bytes: number | unknown): string { - if (typeof bytes !== 'number') return 'Unknown'; - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -} - -/** - * Format parameter count to human-readable format (B, M, K) - * - * @param params - Parameter count - * @returns Human-readable parameter count - */ -export function formatParameters(params: number | unknown): string { - if (typeof params !== 'number') return 'Unknown'; - - if (params >= 1e9) { - return `${(params / 1e9).toFixed(2)}B`; - } - - if (params >= 1e6) { - return `${(params / 1e6).toFixed(2)}M`; - } - - if (params >= 1e3) { - return `${(params / 1e3).toFixed(2)}K`; - } - - return params.toString(); -} - -/** - * Format number with locale-specific thousands separators - * - * @param num - Number to format - * @returns Human-readable number - */ -export function formatNumber(num: number | unknown): string { - if (typeof num !== 'number') return 'Unknown'; - - return num.toLocaleString(); -} - -/** - * Format JSON string with pretty printing (2-space indentation) - * Returns original string if parsing fails - * - * @param jsonString - JSON string to format - * @returns Pretty-printed JSON string or original if invalid - */ -export function formatJsonPretty(jsonString: string): string { - try { - const parsed = JSON.parse(jsonString); - return JSON.stringify(parsed, null, 2); - } catch { - return jsonString; - } -} - -/** - * Format time as HH:MM:SS in 24-hour format - * - * @param date - Date object to format - * @returns Formatted time string (HH:MM:SS) - */ -export function formatTime(date: Date): string { - return date.toLocaleTimeString('en-US', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); -} - -/** - * Formats milliseconds to a human-readable time string for performance metrics. - * Examples: "4h 12min 54s", "12min 34s", "45s", "0.5s" - * - * @param ms - Time in milliseconds - * @returns Formatted time string - */ -export function formatPerformanceTime(ms: number): string { - if (ms < 0) return '0s'; - - const totalSeconds = ms / MS_PER_SECOND; - - if (totalSeconds < SHORT_DURATION_THRESHOLD) { - return `${totalSeconds.toFixed(1)}s`; - } - - if (totalSeconds < MEDIUM_DURATION_THRESHOLD) { - return `${totalSeconds.toFixed(1)}s`; - } - - const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR); - const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE); - const seconds = Math.floor(totalSeconds % SECONDS_PER_MINUTE); - - const parts: string[] = []; - - if (hours > 0) { - parts.push(`${hours}h`); - } - - if (minutes > 0) { - parts.push(`${minutes}min`); - } - - if (seconds > 0 || parts.length === 0) { - parts.push(`${seconds}s`); - } - - return parts.join(' '); -} - -/** - * Formats attachment content for API requests with consistent header style. - * Used when converting message attachments to text content parts. - * - * @param label - Type label (e.g., 'File', 'PDF File', 'MCP Prompt') - * @param name - File or attachment name - * @param content - The actual content to include - * @param extra - Optional extra info to append to name (e.g., server name for MCP) - * @returns Formatted string with header and content - */ -export function formatAttachmentText( - label: string, - name: string, - content: string, - extra?: string -): string { - const header = extra ? `${name} (${extra})` : name; - return `\n\n--- ${label}: ${header} ---\n${content}`; -} diff --git a/tools/server/webui/src/lib/utils/headers.ts b/tools/server/webui/src/lib/utils/headers.ts deleted file mode 100644 index 0b907b830..000000000 --- a/tools/server/webui/src/lib/utils/headers.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Header utilities for parsing and serializing HTTP headers. - * Generic utilities not specific to MCP. - */ - -/** - * Parses a JSON string of headers into an array of key-value pairs. - * Returns empty array if the JSON is invalid or empty. - */ -export function parseHeadersToArray(headersJson: string): { key: string; value: string }[] { - if (!headersJson?.trim()) return []; - - try { - const parsed = JSON.parse(headersJson); - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - return Object.entries(parsed).map(([key, value]) => ({ - key, - value: String(value) - })); - } - } catch { - return []; - } - - return []; -} - -/** - * Serializes an array of header key-value pairs to a JSON string. - * Filters out pairs with empty keys and returns empty string if no valid pairs. - */ -export function serializeHeaders(pairs: { key: string; value: string }[]): string { - const validPairs = pairs.filter((p) => p.key.trim()); - - if (validPairs.length === 0) return ''; - - const obj: Record = {}; - - for (const pair of validPairs) { - obj[pair.key.trim()] = pair.value; - } - - return JSON.stringify(obj); -} diff --git a/tools/server/webui/src/lib/utils/image-error-fallback.ts b/tools/server/webui/src/lib/utils/image-error-fallback.ts deleted file mode 100644 index 6e3260f4a..000000000 --- a/tools/server/webui/src/lib/utils/image-error-fallback.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Simplified HTML fallback for external images that fail to load. - * Displays a centered message with a link to open the image in a new tab. - */ -export function getImageErrorFallbackHtml(src: string): string { - return `
          - Image cannot be displayed - (open link) -
          `; -} diff --git a/tools/server/webui/src/lib/utils/index.ts b/tools/server/webui/src/lib/utils/index.ts deleted file mode 100644 index 386cb30ee..000000000 --- a/tools/server/webui/src/lib/utils/index.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Unified exports for all utility functions - * Import utilities from '$lib/utils' for cleaner imports - * - * For browser-only utilities (pdf-processing, audio-recording, svg-to-png, - * webp-to-png, process-uploaded-files, convert-files-to-extra), use: - * import { ... } from '$lib/utils/browser-only' - */ - -// API utilities -export { getAuthHeaders, getJsonHeaders, sanitizeHeaders } from './api-headers'; -export { apiFetch, apiFetchWithParams, apiPost, type ApiFetchOptions } from './api-fetch'; -export { validateApiKey } from './api-key-validation'; - -// Attachment utilities -export { getAttachmentDisplayItems, isMcpPrompt, isMcpResource } from './attachment-display'; -export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type'; - -// Textarea utilities -export { default as autoResizeTextarea } from './autoresize-textarea'; - -// Branching utilities -export { - filterByLeafNodeId, - findMessageById, - findLeafNode, - findDescendantMessages, - getMessageSiblings, - getMessageDisplayList, - hasMessageSiblings, - getNextSibling, - getPreviousSibling -} from './branching'; - -// Code -export { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from './code'; - -// Config helpers -export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers'; - -// CORS Proxy -export { buildProxiedUrl, buildProxiedHeaders } from './cors-proxy'; - -// URL utilities -export { extractRootDomain, sanitizeExternalUrl } from './url'; - -// Conversation utilities -export { createMessageCountMap, getMessageCount } from './conversation-utils'; - -// Clipboard utilities -export { - copyToClipboard, - copyCodeToClipboard, - formatMessageForClipboard, - parseClipboardContent, - hasClipboardAttachments -} from './clipboard'; - -// File preview utilities -export { getFileTypeLabel } from './file-preview'; -export { getPreviewText, generateConversationTitle } from './text'; - -// File type utilities -export { - getFileTypeCategory, - getFileTypeCategoryByExtension, - getFileTypeByExtension, - isFileTypeSupported -} from './file-type'; - -// Formatting utilities -export { - formatFileSize, - formatParameters, - formatNumber, - formatJsonPretty, - formatTime, - formatPerformanceTime, - formatAttachmentText -} from './formatters'; - -// IME utilities -export { isIMEComposing } from './is-ime-composing'; - -// LaTeX utilities -export { maskInlineLaTeX, preprocessLaTeX } from './latex-protection'; - -// Modality file validation utilities -export { - isFileTypeSupportedByModel, - filterFilesByModalities, - generateModalityErrorMessage -} from './modality-file-validation'; - -// Model name utilities -export { normalizeModelName, isValidModelName } from './model-names'; - -// Portal utilities -export { portalToBody } from './portal-to-body'; - -// Precision utilities -export { normalizeFloatingPoint, normalizeNumber } from './precision'; - -// Syntax highlighting utilities -export { getLanguageFromFilename } from './syntax-highlight-language'; - -// Text file utilities -export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files'; - -// Debounce utilities -export { debounce } from './debounce'; - -// Sanitization utilities -export { sanitizeKeyValuePairKey, sanitizeKeyValuePairValue } from './sanitize'; - -// Image error fallback utilities -export { getImageErrorFallbackHtml } from './image-error-fallback'; - -// MCP utilities -export { - detectMcpTransportFromUrl, - parseMcpServerSettings, - getMcpLogLevelIcon, - getMcpLogLevelClass, - isImageMimeType, - parseResourcePath, - getDisplayName, - getResourceDisplayName, - isCodeResource, - isImageResource, - getResourceIcon, - getResourceTextContent, - getResourceBlobContent, - downloadResourceContent -} from './mcp'; - -// URI Template utilities -export { - extractTemplateVariables, - expandTemplate, - isTemplateComplete, - normalizeResourceUri, - type UriTemplateVariable -} from './uri-template'; - -// Data URL utilities -export { createBase64DataUrl } from './data-url'; - -// Header utilities -export { parseHeadersToArray, serializeHeaders } from './headers'; - -// Agentic content utilities (structured section derivation) -export { - deriveAgenticSections, - parseToolResultWithImages, - hasAgenticContent, - type AgenticSection, - type ToolResultLine -} from './agentic'; - -// Legacy migration utilities -export { runLegacyMigration, isMigrationNeeded } from './legacy-migration'; - -// Cache utilities -export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl'; - -// Redaction utilities -export { redactValue } from './redact'; - -// Request inspection utilities -export { - getRequestUrl, - getRequestMethod, - getRequestBody, - summarizeRequestBody, - formatDiagnosticErrorMessage, - extractJsonRpcMethods, - type RequestBodySummary -} from './request-helpers'; - -// Abort signal utilities -export { - throwIfAborted, - isAbortError, - createLinkedController, - createTimeoutSignal, - withAbortSignal -} from './abort'; - -// Cryptography utilities - -export { uuid } from './uuid'; - -// CSS utilities -export { remToPx } from './css'; diff --git a/tools/server/webui/src/lib/utils/is-ime-composing.ts b/tools/server/webui/src/lib/utils/is-ime-composing.ts deleted file mode 100644 index 9182ea4f3..000000000 --- a/tools/server/webui/src/lib/utils/is-ime-composing.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function isIMEComposing(event: KeyboardEvent) { - // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari, which is notorious for not supporting KeyboardEvent.isComposing) - // This prevents form submission when confirming IME word selection (e.g., Japanese/Chinese input) - return event.isComposing || event.keyCode === 229; -} diff --git a/tools/server/webui/src/lib/utils/latex-protection.ts b/tools/server/webui/src/lib/utils/latex-protection.ts deleted file mode 100644 index 839306978..000000000 --- a/tools/server/webui/src/lib/utils/latex-protection.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { - CODE_BLOCK_REGEXP, - LATEX_MATH_AND_CODE_PATTERN, - LATEX_LINEBREAK_REGEXP, - MHCHEM_PATTERN_MAP -} from '$lib/constants'; - -/** - * Replaces inline LaTeX expressions enclosed in `$...$` with placeholders, avoiding dollar signs - * that appear to be part of monetary values or identifiers. - * - * This function processes the input line by line and skips `$` sequences that are likely - * part of money amounts (e.g., `$5`, `$100.99`) or code-like tokens (e.g., `var$`, `$var`). - * Valid LaTeX inline math is replaced with a placeholder like `<>`, and the - * actual LaTeX content is stored in the provided `latexExpressions` array. - * - * @param content - The input text potentially containing LaTeX expressions. - * @param latexExpressions - An array used to collect extracted LaTeX expressions. - * @returns The processed string with LaTeX replaced by placeholders. - */ -export function maskInlineLaTeX(content: string, latexExpressions: string[]): string { - if (!content.includes('$')) { - return content; - } - return content - .split('\n') - .map((line) => { - if (line.indexOf('$') == -1) { - return line; - } - - let processedLine = ''; - let currentPosition = 0; - - while (currentPosition < line.length) { - const openDollarIndex = line.indexOf('$', currentPosition); - - if (openDollarIndex == -1) { - processedLine += line.slice(currentPosition); - break; - } - - // Is there a next $-sign? - const closeDollarIndex = line.indexOf('$', openDollarIndex + 1); - - if (closeDollarIndex == -1) { - processedLine += line.slice(currentPosition); - break; - } - - const charBeforeOpen = openDollarIndex > 0 ? line[openDollarIndex - 1] : ''; - const charAfterOpen = line[openDollarIndex + 1]; - const charBeforeClose = - openDollarIndex + 1 < closeDollarIndex ? line[closeDollarIndex - 1] : ''; - const charAfterClose = closeDollarIndex + 1 < line.length ? line[closeDollarIndex + 1] : ''; - - let shouldSkipAsNonLatex = false; - - if (closeDollarIndex == currentPosition + 1) { - // No content - shouldSkipAsNonLatex = true; - } - - if (/[A-Za-z0-9_$-]/.test(charBeforeOpen)) { - // Character, digit, $, _ or - before first '$', no TeX. - shouldSkipAsNonLatex = true; - } - - if ( - /[0-9]/.test(charAfterOpen) && - (/[A-Za-z0-9_$-]/.test(charAfterClose) || ' ' == charBeforeClose) - ) { - // First $ seems to belong to an amount. - shouldSkipAsNonLatex = true; - } - - if (shouldSkipAsNonLatex) { - processedLine += line.slice(currentPosition, openDollarIndex + 1); - currentPosition = openDollarIndex + 1; - - continue; - } - - // Treat as LaTeX - processedLine += line.slice(currentPosition, openDollarIndex); - const latexContent = line.slice(openDollarIndex, closeDollarIndex + 1); - latexExpressions.push(latexContent); - processedLine += `<>`; - currentPosition = closeDollarIndex + 1; - } - - return processedLine; - }) - .join('\n'); -} - -function escapeBrackets(text: string): string { - return text.replace( - LATEX_MATH_AND_CODE_PATTERN, - ( - match: string, - codeBlock: string | undefined, - squareBracket: string | undefined, - roundBracket: string | undefined - ): string => { - if (codeBlock != null) { - return codeBlock; - } else if (squareBracket != null) { - return `$$${squareBracket}$$`; - } else if (roundBracket != null) { - return `$${roundBracket}$`; - } - - return match; - } - ); -} - -// Escape $\\ce{...} → $\\ce{...} but with proper handling -function escapeMhchem(text: string): string { - return MHCHEM_PATTERN_MAP.reduce((result, [pattern, replacement]) => { - return result.replace(pattern, replacement); - }, text); -} - -const doEscapeMhchem = false; - -/** - * Preprocesses markdown content to safely handle LaTeX math expressions while protecting - * against false positives (e.g., dollar amounts like $5.99) and ensuring proper rendering. - * - * This function: - * - Protects code blocks (```) and inline code (`...`) - * - Safeguards block and inline LaTeX: \(...\), \[...\], $$...$$, and selective $...$ - * - Escapes standalone dollar signs before numbers (e.g., $5 → \$5) to prevent misinterpretation - * - Restores protected LaTeX and code blocks after processing - * - Converts \(...\) → $...$ and \[...\] → $$...$$ for compatibility with math renderers - * - Applies additional escaping for brackets and mhchem syntax if needed - * - * @param content - The raw text (e.g., markdown) that may contain LaTeX or code blocks. - * @returns The preprocessed string with properly escaped and normalized LaTeX. - * - * @example - * preprocessLaTeX("Price: $10. The equation is \\(x^2\\).") - * // → "Price: $10. The equation is $x^2$." - */ -export function preprocessLaTeX(content: string): string { - // See also: - // https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts - - // Step 0: Temporarily remove blockquote markers (>) to process LaTeX correctly - // Store the structure so we can restore it later - const blockquoteMarkers: Map = new Map(); - const lines = content.split('\n'); - const processedLines = lines.map((line, index) => { - const match = line.match(/^(>\s*)/); - if (match) { - blockquoteMarkers.set(index, match[1]); - return line.slice(match[1].length); - } - return line; - }); - content = processedLines.join('\n'); - - // Step 1: Protect code blocks - const codeBlocks: string[] = []; - - content = content.replace(CODE_BLOCK_REGEXP, (match) => { - codeBlocks.push(match); - - return `<>`; - }); - - // Step 2: Protect existing LaTeX expressions - const latexExpressions: string[] = []; - - // Match \S...\[...\] and protect them and insert a line-break. - content = content.replace(/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g, (match, group1, group2, group3) => { - // Check if there are characters following the formula (display-formula in a table-cell?) - if (group1.endsWith('\\')) { - return match; // Backslash before \[, do nothing. - } - const hasSuffix = /\S/.test(group3); - let optBreak; - - if (hasSuffix) { - latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline. - optBreak = ''; - } else { - latexExpressions.push(`\\[${group2}\\]`); - optBreak = '\n'; - } - - return `${group1}${optBreak}<>${optBreak}${group3}`; - }); - - // Match \(...\), \[...\], $$...$$ and protect them - content = content.replace( - /(\$\$[\s\S]*?\$\$|(? { - latexExpressions.push(match); - - return `<>`; - } - ); - - // Protect inline $...$ but NOT if it looks like money (e.g., $10, $3.99) - content = maskInlineLaTeX(content, latexExpressions); - - // Step 3: Escape standalone $ before digits (currency like $5 → \$5) - // (Now that inline math is protected, this will only escape dollars not already protected) - content = content.replace(/\$(?=\d)/g, '\\$'); - - // Step 4: Restore protected LaTeX expressions (they are valid) - content = content.replace(/<>/g, (_, index) => { - let expr = latexExpressions[parseInt(index)]; - const match = expr.match(LATEX_LINEBREAK_REGEXP); - if (match) { - // Katex: The $$-delimiters should be in their own line - // if there are \\-line-breaks. - const formula = match[1]; - const prefix = formula.startsWith('\n') ? '' : '\n'; - const suffix = formula.endsWith('\n') ? '' : '\n'; - expr = '$$' + prefix + formula + suffix + '$$'; - } - return expr; - }); - - // Step 5: Apply additional escaping functions (brackets and mhchem) - // This must happen BEFORE restoring code blocks to avoid affecting code content - content = escapeBrackets(content); - - if (doEscapeMhchem && (content.includes('\\ce{') || content.includes('\\pu{'))) { - content = escapeMhchem(content); - } - - // Step 6: Convert remaining \(...\) → $...$, \[...\] → $$...$$ - // This must happen BEFORE restoring code blocks to avoid affecting code content - content = content - // Using the look‑behind pattern `(? { - return `$$${content}$$`; - } - ); - - // Step 7: Restore code blocks - // This happens AFTER all LaTeX conversions to preserve code content - content = content.replace(/<>/g, (_, index) => { - return codeBlocks[parseInt(index)]; - }); - - // Step 8: Restore blockquote markers - if (blockquoteMarkers.size > 0) { - const finalLines = content.split('\n'); - const restoredLines = finalLines.map((line, index) => { - const marker = blockquoteMarkers.get(index); - return marker ? marker + line : line; - }); - content = restoredLines.join('\n'); - } - - return content; -} diff --git a/tools/server/webui/src/lib/utils/legacy-migration.ts b/tools/server/webui/src/lib/utils/legacy-migration.ts deleted file mode 100644 index b526c2609..000000000 --- a/tools/server/webui/src/lib/utils/legacy-migration.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * @deprecated Legacy migration utility — remove at some point in the future once all users have migrated to the new structured agentic message format. - * - * Converts old marker-based agentic messages to the new structured format - * with separate messages per turn. - * - * Old format: Single assistant message with markers in content: - * <<>>...<<>> - * <<>>...<<>> - * - * New format: Separate messages per turn: - * - assistant (content + reasoningContent + toolCalls) - * - tool (toolCallId + content) - * - assistant (next turn) - * - ... - */ - -import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants'; -import { DatabaseService } from '$lib/services/database.service'; -import { MessageRole, MessageType } from '$lib/enums'; -import type { DatabaseMessage } from '$lib/types/database'; - -const MIGRATION_DONE_KEY = 'llama-webui-migration-v2-done'; - -/** - * @deprecated Part of legacy migration — remove with the migration module. - * Check if migration has been performed. - */ -export function isMigrationNeeded(): boolean { - try { - return !localStorage.getItem(MIGRATION_DONE_KEY); - } catch { - return false; - } -} - -/** - * Mark migration as done. - */ -function markMigrationDone(): void { - try { - localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now())); - } catch { - // Ignore localStorage errors - } -} - -/** - * Check if a message has legacy markers in its content. - */ -function hasLegacyMarkers(message: DatabaseMessage): boolean { - if (!message.content) return false; - return LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test(message.content); -} - -/** - * Extract reasoning content from legacy marker format. - */ -function extractLegacyReasoning(content: string): { reasoning: string; cleanContent: string } { - let reasoning = ''; - let cleanContent = content; - - // Extract all reasoning blocks - const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g'); - let match; - while ((match = re.exec(content)) !== null) { - reasoning += match[1]; - } - - // Remove reasoning tags from content - cleanContent = cleanContent - .replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '') - .replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, ''); - - return { reasoning, cleanContent }; -} - -/** - * Parse legacy content with tool call markers into structured turns. - */ -interface ParsedTurn { - textBefore: string; - toolCalls: Array<{ - name: string; - args: string; - result: string; - }>; -} - -function parseLegacyToolCalls(content: string): ParsedTurn[] { - const turns: ParsedTurn[] = []; - const regex = new RegExp(LEGACY_AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g'); - - let lastIndex = 0; - let currentTurn: ParsedTurn = { textBefore: '', toolCalls: [] }; - let match; - - while ((match = regex.exec(content)) !== null) { - const textBefore = content.slice(lastIndex, match.index).trim(); - - // If there's text between tool calls and we already have tool calls, - // that means a new turn started (text after tool results = new LLM turn) - if (textBefore && currentTurn.toolCalls.length > 0) { - turns.push(currentTurn); - currentTurn = { textBefore, toolCalls: [] }; - } else if (textBefore && currentTurn.toolCalls.length === 0) { - currentTurn.textBefore = textBefore; - } - - currentTurn.toolCalls.push({ - name: match[1], - args: match[2], - result: match[3].replace(/^\n+|\n+$/g, '') - }); - - lastIndex = match.index + match[0].length; - } - - // Any remaining text after the last tool call - const remainingText = content.slice(lastIndex).trim(); - - if (currentTurn.toolCalls.length > 0) { - turns.push(currentTurn); - } - - // If there's text after all tool calls, it's the final assistant response - if (remainingText) { - // Remove any partial/open markers - const cleanRemaining = remainingText - .replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '') - .trim(); - if (cleanRemaining) { - turns.push({ textBefore: cleanRemaining, toolCalls: [] }); - } - } - - // If no tool calls found at all, return the original content as a single turn - if (turns.length === 0) { - turns.push({ textBefore: content.trim(), toolCalls: [] }); - } - - return turns; -} - -/** - * Migrate a single conversation's messages from legacy format to new format. - */ -async function migrateConversation(convId: string): Promise { - const allMessages = await DatabaseService.getConversationMessages(convId); - let migratedCount = 0; - - for (const message of allMessages) { - if (message.role !== MessageRole.ASSISTANT) continue; - if (!hasLegacyMarkers(message)) { - // Still check for reasoning-only markers (no tool calls) - if (message.content?.includes(LEGACY_REASONING_TAGS.START)) { - const { reasoning, cleanContent } = extractLegacyReasoning(message.content); - await DatabaseService.updateMessage(message.id, { - content: cleanContent.trim(), - reasoningContent: reasoning || undefined - }); - migratedCount++; - } - continue; - } - - // Has agentic markers - full migration needed - const { reasoning, cleanContent } = extractLegacyReasoning(message.content); - const turns = parseLegacyToolCalls(cleanContent); - - // Parse existing toolCalls JSON to try to match IDs - let existingToolCalls: Array<{ id: string; function?: { name: string; arguments: string } }> = - []; - if (message.toolCalls) { - try { - existingToolCalls = JSON.parse(message.toolCalls); - } catch { - // Ignore - } - } - - // First turn uses the existing message - const firstTurn = turns[0]; - if (!firstTurn) continue; - - // Match tool calls from the first turn to existing IDs - const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => { - const existing = - existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i]; - return { - id: existing?.id || `legacy_tool_${i}`, - type: 'function' as const, - function: { name: tc.name, arguments: tc.args } - }; - }); - - // Update the existing message for the first turn - await DatabaseService.updateMessage(message.id, { - content: firstTurn.textBefore, - reasoningContent: reasoning || undefined, - toolCalls: firstTurnToolCalls.length > 0 ? JSON.stringify(firstTurnToolCalls) : '' - }); - - let currentParentId = message.id; - let toolCallIdCounter = existingToolCalls.length; - - // Create tool result messages for the first turn - for (let i = 0; i < firstTurn.toolCalls.length; i++) { - const tc = firstTurn.toolCalls[i]; - const toolCallId = firstTurnToolCalls[i]?.id || `legacy_tool_${i}`; - - const toolMsg = await DatabaseService.createMessageBranch( - { - convId, - type: MessageType.TEXT, - role: MessageRole.TOOL, - content: tc.result, - toolCallId, - timestamp: message.timestamp + i + 1, - toolCalls: '', - children: [] - }, - currentParentId - ); - currentParentId = toolMsg.id; - } - - // Create messages for subsequent turns - for (let turnIdx = 1; turnIdx < turns.length; turnIdx++) { - const turn = turns[turnIdx]; - - const turnToolCalls = turn.toolCalls.map((tc, i) => { - const idx = toolCallIdCounter + i; - const existing = existingToolCalls[idx]; - return { - id: existing?.id || `legacy_tool_${idx}`, - type: 'function' as const, - function: { name: tc.name, arguments: tc.args } - }; - }); - toolCallIdCounter += turn.toolCalls.length; - - // Create assistant message for this turn - const assistantMsg = await DatabaseService.createMessageBranch( - { - convId, - type: MessageType.TEXT, - role: MessageRole.ASSISTANT, - content: turn.textBefore, - timestamp: message.timestamp + turnIdx * 100, - toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '', - children: [], - model: message.model - }, - currentParentId - ); - currentParentId = assistantMsg.id; - - // Create tool result messages for this turn - for (let i = 0; i < turn.toolCalls.length; i++) { - const tc = turn.toolCalls[i]; - const toolCallId = turnToolCalls[i]?.id || `legacy_tool_${toolCallIdCounter + i}`; - - const toolMsg = await DatabaseService.createMessageBranch( - { - convId, - type: MessageType.TEXT, - role: MessageRole.TOOL, - content: tc.result, - toolCallId, - timestamp: message.timestamp + turnIdx * 100 + i + 1, - toolCalls: '', - children: [] - }, - currentParentId - ); - currentParentId = toolMsg.id; - } - } - - // Re-parent any children of the original message to the last created message - // (the original message's children list was the next user message or similar) - if (message.children.length > 0 && currentParentId !== message.id) { - for (const childId of message.children) { - // Skip children we just created (they were already properly parented) - const child = allMessages.find((m) => m.id === childId); - if (!child) continue; - // Only re-parent non-tool messages that were original children - if (child.role !== MessageRole.TOOL) { - await DatabaseService.updateMessage(childId, { parent: currentParentId }); - // Add to new parent's children - const newParent = await DatabaseService.getConversationMessages(convId).then((msgs) => - msgs.find((m) => m.id === currentParentId) - ); - if (newParent && !newParent.children.includes(childId)) { - await DatabaseService.updateMessage(currentParentId, { - children: [...newParent.children, childId] - }); - } - } - } - // Clear re-parented children from the original message - await DatabaseService.updateMessage(message.id, { children: [] }); - } - - migratedCount++; - } - - return migratedCount; -} - -/** - * @deprecated Part of legacy migration — remove with the migration module. - * Run the full migration across all conversations. - * This should be called once at app startup if migration is needed. - */ -export async function runLegacyMigration(): Promise { - if (!isMigrationNeeded()) return; - - console.log('[Migration] Starting legacy message format migration...'); - - try { - const conversations = await DatabaseService.getAllConversations(); - let totalMigrated = 0; - - for (const conv of conversations) { - const count = await migrateConversation(conv.id); - totalMigrated += count; - } - - if (totalMigrated > 0) { - console.log( - `[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations` - ); - } else { - console.log('[Migration] No legacy messages found, marking as done'); - } - - markMigrationDone(); - } catch (error) { - console.error('[Migration] Failed to migrate legacy messages:', error); - // Still mark as done to avoid infinite retry loops - markMigrationDone(); - } -} diff --git a/tools/server/webui/src/lib/utils/mcp.ts b/tools/server/webui/src/lib/utils/mcp.ts deleted file mode 100644 index ee2779845..000000000 --- a/tools/server/webui/src/lib/utils/mcp.ts +++ /dev/null @@ -1,304 +0,0 @@ -import type { MCPServerSettingsEntry, MCPResourceContent, MCPResourceInfo } from '$lib/types'; -import { - MCPTransportType, - MCPLogLevel, - UrlProtocol, - MimeTypePrefix, - MimeTypeIncludes, - UriPattern, - MimeTypeText -} from '$lib/enums'; -import { - DEFAULT_MCP_CONFIG, - MCP_SERVER_ID_PREFIX, - IMAGE_FILE_EXTENSION_REGEX, - CODE_FILE_EXTENSION_REGEX, - TEXT_FILE_EXTENSION_REGEX, - PROTOCOL_PREFIX_REGEX, - FILE_EXTENSION_REGEX, - DISPLAY_NAME_SEPARATOR_REGEX, - PATH_SEPARATOR, - RESOURCE_TEXT_CONTENT_SEPARATOR, - DEFAULT_RESOURCE_FILENAME -} from '$lib/constants'; -import { - Database, - File, - FileText, - Image, - Code, - Info, - AlertTriangle, - XCircle -} from '@lucide/svelte'; -import type { Component } from 'svelte'; -import type { MimeTypeUnion } from '$lib/types/common'; - -/** - * Detects the MCP transport type from a URL. - * WebSocket URLs (ws:// or wss://) use 'websocket', others use 'streamable_http'. - */ -export function detectMcpTransportFromUrl(url: string): MCPTransportType { - const normalized = url.trim().toLowerCase(); - - return normalized.startsWith(UrlProtocol.WEBSOCKET) || - normalized.startsWith(UrlProtocol.WEBSOCKET_SECURE) - ? MCPTransportType.WEBSOCKET - : MCPTransportType.STREAMABLE_HTTP; -} - -/** - * Parses MCP server settings from a JSON string or array. - * requestTimeoutSeconds is not user-configurable in the UI, so we always use the default value. - * @param rawServers - The raw servers to parse - * @returns An empty array if the input is invalid. - */ -export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEntry[] { - if (!rawServers) return []; - - let parsed: unknown; - - if (typeof rawServers === 'string') { - const trimmed = rawServers.trim(); - if (!trimmed) return []; - - try { - parsed = JSON.parse(trimmed); - } catch (error) { - console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error); - - return []; - } - } else { - parsed = rawServers; - } - - if (!Array.isArray(parsed)) return []; - - return parsed.map((entry, index) => { - const url = typeof entry?.url === 'string' ? entry.url.trim() : ''; - const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined; - const id = - typeof (entry as { id?: unknown })?.id === 'string' && (entry as { id?: string }).id?.trim() - ? (entry as { id: string }).id.trim() - : `${MCP_SERVER_ID_PREFIX}-${index + 1}`; - - return { - id, - enabled: Boolean((entry as { enabled?: unknown })?.enabled), - url, - name: (entry as { name?: string })?.name, - requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds, - headers: headers || undefined, - useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy) - } satisfies MCPServerSettingsEntry; - }); -} - -/** - * Get the appropriate icon component for a log level - * - * @param level - MCP log level - * @returns Lucide icon component - */ -export function getMcpLogLevelIcon(level: MCPLogLevel): Component { - switch (level) { - case MCPLogLevel.ERROR: - return XCircle; - case MCPLogLevel.WARN: - return AlertTriangle; - default: - return Info; - } -} - -/** - * Get the appropriate CSS class for a log level - * - * @param level - MCP log level - * @returns Tailwind CSS class string - */ -export function getMcpLogLevelClass(level: MCPLogLevel): string { - switch (level) { - case MCPLogLevel.ERROR: - return 'text-destructive'; - case MCPLogLevel.WARN: - return 'text-yellow-600 dark:text-yellow-500'; - default: - return 'text-muted-foreground'; - } -} - -/** - * Check if a MIME type represents an image. - * - * @param mimeType - The MIME type to check - * @returns True if the MIME type starts with 'image/' - */ -export function isImageMimeType(mimeType?: MimeTypeUnion): boolean { - return mimeType?.startsWith(MimeTypePrefix.IMAGE) ?? false; -} - -/** - * Parse a resource URI into path segments, stripping the protocol prefix. - * - * @param uri - The resource URI to parse - * @returns Array of non-empty path segments - */ -export function parseResourcePath(uri: string): string[] { - try { - const withoutProtocol = uri.replace(PROTOCOL_PREFIX_REGEX, ''); - return withoutProtocol.split(PATH_SEPARATOR).filter((p) => p.length > 0); - } catch { - return [uri]; - } -} - -/** - * Convert a path part into a human-readable display name. - * Strips file extensions and converts kebab-case/snake_case to Title Case. - * - * @param pathPart - The path segment to convert - * @returns Human-readable display name - */ -export function getDisplayName(pathPart: string): string { - const withoutExt = pathPart.replace(FILE_EXTENSION_REGEX, ''); - return withoutExt - .split(DISPLAY_NAME_SEPARATOR_REGEX) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); -} - -/** - * Get the display name from a resource, extracting the last path segment from the URI. - * - * @param resource - The MCP resource info - * @returns Display name string - */ -export function getResourceDisplayName(resource: MCPResourceInfo): string { - try { - const parts = parseResourcePath(resource.uri); - return parts[parts.length - 1] || resource.name || resource.uri; - } catch { - return resource.name || resource.uri; - } -} - -/** - * Determine if a MIME type and/or URI represents code content. - * - * @param mimeType - Optional MIME type string - * @param uri - Optional URI string - * @returns True if the content is code - */ -export function isCodeResource(mimeType?: MimeTypeUnion, uri?: string): boolean { - const mime = mimeType?.toLowerCase() || ''; - const u = uri?.toLowerCase() || ''; - return ( - mime.includes(MimeTypeIncludes.JSON) || - mime.includes(MimeTypeIncludes.JAVASCRIPT) || - mime.includes(MimeTypeIncludes.TYPESCRIPT) || - CODE_FILE_EXTENSION_REGEX.test(u) - ); -} - -/** - * Determine if a MIME type and/or URI represents image content. - * - * @param mimeType - Optional MIME type string - * @param uri - Optional URI string - * @returns True if the content is an image - */ -export function isImageResource(mimeType?: MimeTypeUnion, uri?: string): boolean { - const mime = mimeType?.toLowerCase() || ''; - const u = uri?.toLowerCase() || ''; - return mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u); -} - -/** - * Get the appropriate Lucide icon component for an MCP resource based on its MIME type and URI. - * - * @param mimeType - Optional MIME type of the resource - * @param uri - Optional URI of the resource - * @returns Lucide icon component - */ -export function getResourceIcon(mimeType?: MimeTypeUnion, uri?: string): Component { - const mime = mimeType?.toLowerCase() || ''; - const u = uri?.toLowerCase() || ''; - - if (mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u)) { - return Image; - } - - if ( - mime.includes(MimeTypeIncludes.JSON) || - mime.includes(MimeTypeIncludes.JAVASCRIPT) || - mime.includes(MimeTypeIncludes.TYPESCRIPT) || - CODE_FILE_EXTENSION_REGEX.test(u) - ) { - return Code; - } - - if (mime.includes(MimeTypePrefix.TEXT) || TEXT_FILE_EXTENSION_REGEX.test(u)) { - return FileText; - } - - if (u.includes(UriPattern.DATABASE_KEYWORD) || u.includes(UriPattern.DATABASE_SCHEME)) { - return Database; - } - - return File; -} - -/** - * Extract text content from MCP resource content array. - * - * @param content - Array of MCP resource content items - * @returns Joined text content string - */ -export function getResourceTextContent(content: MCPResourceContent[] | null | undefined): string { - if (!content) return ''; - return content - .filter((c): c is { uri: string; mimeType?: MimeTypeUnion; text: string } => 'text' in c) - .map((c) => c.text) - .join(RESOURCE_TEXT_CONTENT_SEPARATOR); -} - -/** - * Extract blob content from MCP resource content array. - * - * @param content - Array of MCP resource content items - * @returns Array of blob content items - */ -export function getResourceBlobContent( - content: MCPResourceContent[] | null | undefined -): Array<{ uri: string; mimeType?: MimeTypeUnion; blob: string }> { - if (!content) return []; - - return content.filter( - (c): c is { uri: string; mimeType?: MimeTypeUnion; blob: string } => 'blob' in c - ); -} - -/** - * Trigger a file download from text content. - * - * @param text - The text content to download - * @param mimeType - MIME type for the blob - * @param filename - Suggested filename - */ -export function downloadResourceContent( - text: string, - mimeType: MimeTypeUnion = MimeTypeText.PLAIN, - filename: string = DEFAULT_RESOURCE_FILENAME -): void { - const blob = new Blob([text], { type: mimeType }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} diff --git a/tools/server/webui/src/lib/utils/modality-file-validation.ts b/tools/server/webui/src/lib/utils/modality-file-validation.ts deleted file mode 100644 index 9b52e93db..000000000 --- a/tools/server/webui/src/lib/utils/modality-file-validation.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * File validation utilities based on model modalities - * Ensures only compatible file types are processed based on model capabilities - */ - -import { getFileTypeCategory } from '$lib/utils'; -import { FileTypeCategory } from '$lib/enums'; -import type { ModalityCapabilities } from '$lib/types'; - -/** - * Check if a file type is supported by the given modalities - * @param filename - The filename to check - * @param mimeType - The MIME type of the file - * @param capabilities - The modality capabilities to check against - * @returns true if the file type is supported - */ -export function isFileTypeSupportedByModel( - filename: string, - mimeType: string | undefined, - capabilities: ModalityCapabilities -): boolean { - const category = mimeType ? getFileTypeCategory(mimeType) : null; - - // If we can't determine the category from MIME type, fall back to general support check - if (!category) { - // For unknown types, only allow if they might be text files - // This is a conservative approach for edge cases - return true; // Let the existing isFileTypeSupported handle this - } - - switch (category) { - case FileTypeCategory.TEXT: - // Text files are always supported - return true; - - case FileTypeCategory.PDF: - // PDFs are always supported (will be processed as text for non-vision models) - return true; - - case FileTypeCategory.IMAGE: - // Images require vision support - return capabilities.hasVision; - - case FileTypeCategory.AUDIO: - // Audio files require audio support - return capabilities.hasAudio; - - default: - // Unknown categories - be conservative and allow - return true; - } -} - -/** - * Filter files based on model modalities and return supported/unsupported lists - * @param files - Array of files to filter - * @param capabilities - The modality capabilities to check against - * @returns Object with supportedFiles and unsupportedFiles arrays - */ -export function filterFilesByModalities( - files: File[], - capabilities: ModalityCapabilities -): { - supportedFiles: File[]; - unsupportedFiles: File[]; - modalityReasons: Record; -} { - const supportedFiles: File[] = []; - const unsupportedFiles: File[] = []; - const modalityReasons: Record = {}; - - const { hasVision, hasAudio } = capabilities; - - for (const file of files) { - const category = getFileTypeCategory(file.type); - let isSupported = true; - let reason = ''; - - switch (category) { - case FileTypeCategory.IMAGE: - if (!hasVision) { - isSupported = false; - reason = 'Images require a vision-capable model'; - } - break; - - case FileTypeCategory.AUDIO: - if (!hasAudio) { - isSupported = false; - reason = 'Audio files require an audio-capable model'; - } - break; - - case FileTypeCategory.TEXT: - case FileTypeCategory.PDF: - // Always supported - break; - - default: - // For unknown types, check if it's a generally supported file type - // This handles edge cases and maintains backward compatibility - break; - } - - if (isSupported) { - supportedFiles.push(file); - } else { - unsupportedFiles.push(file); - modalityReasons[file.name] = reason; - } - } - - return { supportedFiles, unsupportedFiles, modalityReasons }; -} - -/** - * Generate a user-friendly error message for unsupported files - * @param unsupportedFiles - Array of unsupported files - * @param modalityReasons - Reasons why files are unsupported - * @param capabilities - The modality capabilities to check against - * @returns Formatted error message - */ -export function generateModalityErrorMessage( - unsupportedFiles: File[], - modalityReasons: Record, - capabilities: ModalityCapabilities -): string { - if (unsupportedFiles.length === 0) return ''; - - const { hasVision, hasAudio } = capabilities; - - let message = ''; - - if (unsupportedFiles.length === 1) { - const file = unsupportedFiles[0]; - const reason = modalityReasons[file.name]; - message = `The file "${file.name}" cannot be uploaded: ${reason}.`; - } else { - const fileNames = unsupportedFiles.map((f) => f.name).join(', '); - message = `The following files cannot be uploaded: ${fileNames}.`; - } - - // Add helpful information about what is supported - const supportedTypes: string[] = ['text files', 'PDFs']; - if (hasVision) supportedTypes.push('images'); - if (hasAudio) supportedTypes.push('audio files'); - - message += ` This model supports: ${supportedTypes.join(', ')}.`; - - return message; -} - -/** - * Generate file input accept string based on model modalities - * @param capabilities - The modality capabilities to check against - * @returns Accept string for HTML file input element - */ diff --git a/tools/server/webui/src/lib/utils/model-names.ts b/tools/server/webui/src/lib/utils/model-names.ts deleted file mode 100644 index c0a1e1c57..000000000 --- a/tools/server/webui/src/lib/utils/model-names.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Normalizes a model name by extracting the filename from a path, but preserves Hugging Face repository format. - * - * Handles both forward slashes (/) and backslashes (\) as path separators. - * - If the model name has exactly one slash (org/model format), preserves the full "org/model" name - * - If the model name has no slash or multiple slashes, extracts just the filename - * - If the model name is just a filename (no path), returns it as-is. - * - * @param modelName - The model name or path to normalize - * @returns The normalized model name - * - * @example - * normalizeModelName('models/llama-3.1-8b') // Returns: 'llama-3.1-8b' (multiple slashes -> filename) - * normalizeModelName('C:\\Models\\gpt-4') // Returns: 'gpt-4' (multiple slashes -> filename) - * normalizeModelName('meta-llama/Llama-3.1-8B') // Returns: 'meta-llama/Llama-3.1-8B' (Hugging Face format) - * normalizeModelName('simple-model') // Returns: 'simple-model' (no slash) - * normalizeModelName(' spaced ') // Returns: 'spaced' - * normalizeModelName('') // Returns: '' - */ -export function normalizeModelName(modelName: string): string { - const trimmed = modelName.trim(); - - if (!trimmed) { - return ''; - } - - const segments = trimmed.split(/[\\/]/); - - // If we have exactly 2 segments (one slash), treat it as Hugging Face repo format - // and preserve the full "org/model" format - if (segments.length === 2) { - const [org, model] = segments; - const trimmedOrg = org?.trim(); - const trimmedModel = model?.trim(); - - if (trimmedOrg && trimmedModel) { - return `${trimmedOrg}/${trimmedModel}`; - } - } - - // For other cases (no slash, or multiple slashes), extract just the filename - const candidate = segments.pop(); - const normalized = candidate?.trim(); - - return normalized && normalized.length > 0 ? normalized : trimmed; -} - -/** - * Validates if a model name is valid (non-empty after normalization). - * - * @param modelName - The model name to validate - * @returns true if valid, false otherwise - */ -export function isValidModelName(modelName: string): boolean { - return normalizeModelName(modelName).length > 0; -} diff --git a/tools/server/webui/src/lib/utils/pdf-processing.ts b/tools/server/webui/src/lib/utils/pdf-processing.ts deleted file mode 100644 index 84c456d10..000000000 --- a/tools/server/webui/src/lib/utils/pdf-processing.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * PDF processing utilities using PDF.js - * Handles PDF text extraction and image conversion in the browser - */ - -import { browser } from '$app/environment'; -import { MimeTypeApplication, MimeTypeImage } from '$lib/enums'; -import * as pdfjs from 'pdfjs-dist'; - -type TextContent = { - items: Array<{ str: string }>; -}; - -if (browser) { - // Import worker as text and create blob URL for inline bundling - import('pdfjs-dist/build/pdf.worker.min.mjs?raw') - .then((workerModule) => { - const workerBlob = new Blob([workerModule.default], { type: 'application/javascript' }); - pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(workerBlob); - }) - .catch(() => { - console.warn('Failed to load PDF.js worker, PDF processing may not work'); - }); -} - -/** - * Convert a File object to ArrayBuffer for PDF.js processing - * @param file - The PDF file to convert - * @returns Promise resolving to the file's ArrayBuffer - */ -async function getFileAsBuffer(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (event) => { - if (event.target?.result) { - resolve(event.target.result as ArrayBuffer); - } else { - reject(new Error('Failed to read file.')); - } - }; - reader.onerror = () => { - reject(new Error('Failed to read file.')); - }; - reader.readAsArrayBuffer(file); - }); -} - -/** - * Extract text content from a PDF file - * @param file - The PDF file to process - * @returns Promise resolving to the extracted text content - */ -export async function convertPDFToText(file: File): Promise { - if (!browser) { - throw new Error('PDF processing is only available in the browser'); - } - - try { - const buffer = await getFileAsBuffer(file); - const pdf = await pdfjs.getDocument(buffer).promise; - const numPages = pdf.numPages; - - const textContentPromises: Promise[] = []; - - for (let i = 1; i <= numPages; i++) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - textContentPromises.push(pdf.getPage(i).then((page: any) => page.getTextContent())); - } - - const textContents = await Promise.all(textContentPromises); - const textItems = textContents.flatMap((textContent: TextContent) => - textContent.items.map((item) => item.str ?? '') - ); - - return textItems.join('\n'); - } catch (error) { - console.error('Error converting PDF to text:', error); - throw new Error( - `Failed to convert PDF to text: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Convert PDF pages to PNG images as data URLs - * @param file - The PDF file to convert - * @param scale - Rendering scale factor (default: 1.5) - * @returns Promise resolving to array of PNG data URLs - */ -export async function convertPDFToImage(file: File, scale: number = 1.5): Promise { - if (!browser) { - throw new Error('PDF processing is only available in the browser'); - } - - try { - const buffer = await getFileAsBuffer(file); - const doc = await pdfjs.getDocument(buffer).promise; - const pages: Promise[] = []; - - for (let i = 1; i <= doc.numPages; i++) { - const page = await doc.getPage(i); - const viewport = page.getViewport({ scale }); - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - - canvas.width = viewport.width; - canvas.height = viewport.height; - - if (!ctx) { - throw new Error('Failed to get 2D context from canvas'); - } - - const task = page.render({ - canvasContext: ctx, - viewport: viewport, - canvas: canvas - }); - pages.push( - task.promise.then(() => { - return canvas.toDataURL(MimeTypeImage.PNG); - }) - ); - } - - return await Promise.all(pages); - } catch (error) { - console.error('Error converting PDF to images:', error); - throw new Error( - `Failed to convert PDF to images: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Check if a file is a PDF based on its MIME type - * @param file - The file to check - * @returns True if the file is a PDF - */ -export function isPdfFile(file: File): boolean { - return file.type === MimeTypeApplication.PDF; -} - -/** - * Check if a MIME type represents a PDF - * @param mimeType - The MIME type to check - * @returns True if the MIME type is application/pdf - */ -export function isApplicationMimeType(mimeType: string): boolean { - return mimeType === MimeTypeApplication.PDF; -} diff --git a/tools/server/webui/src/lib/utils/portal-to-body.ts b/tools/server/webui/src/lib/utils/portal-to-body.ts deleted file mode 100644 index bffbe8900..000000000 --- a/tools/server/webui/src/lib/utils/portal-to-body.ts +++ /dev/null @@ -1,20 +0,0 @@ -export function portalToBody(node: HTMLElement) { - if (typeof document === 'undefined') { - return; - } - - const target = document.body; - if (!target) { - return; - } - - target.appendChild(node); - - return { - destroy() { - if (node.parentNode === target) { - target.removeChild(node); - } - } - }; -} diff --git a/tools/server/webui/src/lib/utils/precision.ts b/tools/server/webui/src/lib/utils/precision.ts deleted file mode 100644 index 500281dc9..000000000 --- a/tools/server/webui/src/lib/utils/precision.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Floating-point precision utilities - * - * Provides functions to normalize floating-point numbers for consistent comparison - * and display, addressing JavaScript's floating-point precision issues. - */ - -import { PRECISION_MULTIPLIER } from '$lib/constants'; - -/** - * Normalize floating-point numbers for consistent comparison - * Addresses JavaScript floating-point precision issues (e.g., 0.949999988079071 → 0.95) - */ -export function normalizeFloatingPoint(value: unknown): unknown { - return typeof value === 'number' - ? Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER - : value; -} - -/** - * Type-safe version that only accepts numbers - */ -export function normalizeNumber(value: number): number { - return Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER; -} diff --git a/tools/server/webui/src/lib/utils/process-uploaded-files.ts b/tools/server/webui/src/lib/utils/process-uploaded-files.ts deleted file mode 100644 index 1f4068aee..000000000 --- a/tools/server/webui/src/lib/utils/process-uploaded-files.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png'; -import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png'; -import { FileTypeCategory } from '$lib/enums'; -import { SETTINGS_KEYS } from '$lib/constants'; -import { modelsStore } from '$lib/stores/models.svelte'; -import { settingsStore } from '$lib/stores/settings.svelte'; -import { toast } from 'svelte-sonner'; -import { getFileTypeCategory } from '$lib/utils'; -import { convertPDFToText } from './pdf-processing'; - -/** - * Read a file as a data URL (base64 encoded) - * @param file - The file to read - * @returns Promise resolving to the data URL string - */ -function readFileAsDataURL(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(file); - }); -} - -/** - * Read a file as UTF-8 text - * @param file - The file to read - * @returns Promise resolving to the text content - */ -function readFileAsUTF8(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = () => reject(reader.error); - reader.readAsText(file); - }); -} - -/** - * Process uploaded files into ChatUploadedFile format with previews and content - * - * This function processes various file types and generates appropriate previews: - * - Images: Base64 data URLs with format normalization (SVG/WebP → PNG) - * - Text files: UTF-8 content extraction - * - PDFs: Metadata only (processed later in conversion pipeline) - * - Audio: Base64 data URLs for preview - * - * @param files - Array of File objects to process - * @returns Promise resolving to array of ChatUploadedFile objects - */ -export async function processFilesToChatUploaded( - files: File[], - activeModelId?: string -): Promise { - const results: ChatUploadedFile[] = []; - - for (const file of files) { - const id = Date.now().toString() + Math.random().toString(36).substr(2, 9); - const base: ChatUploadedFile = { - id, - name: file.name, - size: file.size, - type: file.type, - file - }; - - try { - if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) { - let preview = await readFileAsDataURL(file); - - // Normalize SVG and WebP to PNG in previews - if (isSvgMimeType(file.type)) { - try { - preview = await svgBase64UrlToPngDataURL(preview); - } catch (err) { - console.error('Failed to convert SVG to PNG:', err); - } - } else if (isWebpMimeType(file.type)) { - try { - preview = await webpBase64UrlToPngDataURL(preview); - } catch (err) { - console.error('Failed to convert WebP to PNG:', err); - } - } - - results.push({ ...base, preview }); - } else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) { - // Extract text content from PDF for preview - try { - const textContent = await convertPDFToText(file); - results.push({ ...base, textContent }); - } catch (err) { - console.warn('Failed to extract text from PDF, adding without content:', err); - results.push(base); - } - - // Show suggestion toast if vision model is available but PDF as image is disabled - const hasVisionSupport = activeModelId - ? modelsStore.modelSupportsVision(activeModelId) - : false; - const currentConfig = settingsStore.config; - if (hasVisionSupport && !currentConfig.pdfAsImage) { - toast.info(`You can enable parsing PDF as images with vision models.`, { - duration: 8000, - action: { - label: 'Enable PDF as Images', - onClick: () => { - settingsStore.updateConfig(SETTINGS_KEYS.PDF_AS_IMAGE, true); - toast.success('PDF parsing as images enabled!', { - duration: 3000 - }); - } - } - }); - } - } else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) { - // Generate preview URL for audio files - const preview = await readFileAsDataURL(file); - results.push({ ...base, preview }); - } else { - // Fallback: treat unknown files as text - try { - const textContent = await readFileAsUTF8(file); - results.push({ ...base, textContent }); - } catch (err) { - console.warn('Failed to read file as text, adding without content:', err); - results.push(base); - } - } - } catch (error) { - console.error('Error processing file', file.name, error); - results.push(base); - } - } - - return results; -} diff --git a/tools/server/webui/src/lib/utils/redact.ts b/tools/server/webui/src/lib/utils/redact.ts deleted file mode 100644 index 851be7bf4..000000000 --- a/tools/server/webui/src/lib/utils/redact.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Redacts a sensitive value, optionally showing the last N characters. - * - * @param value - The value to redact - * @param showLastChars - If provided, reveals the last N characters with a leading mask - * @returns The redacted string - */ -export function redactValue(value: string, showLastChars?: number): string { - if (showLastChars) { - return `....${value.slice(-showLastChars)}`; - } - - return '[redacted]'; -} diff --git a/tools/server/webui/src/lib/utils/request-helpers.ts b/tools/server/webui/src/lib/utils/request-helpers.ts deleted file mode 100644 index 8a11b8fb5..000000000 --- a/tools/server/webui/src/lib/utils/request-helpers.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * HTTP request inspection utilities for diagnostic logging. - * These helpers extract metadata from fetch-style request arguments - * without exposing sensitive payload data. - */ - -export interface RequestBodySummary { - kind: string; - size?: number; -} - -export function getRequestUrl(input: RequestInfo | URL): string { - if (typeof input === 'string') { - return input; - } - - if (input instanceof URL) { - return input.href; - } - - return input.url; -} - -export function getRequestMethod( - input: RequestInfo | URL, - init?: RequestInit, - baseInit?: RequestInit -): string { - if (init?.method) { - return init.method; - } - - if (typeof Request !== 'undefined' && input instanceof Request) { - return input.method; - } - - return baseInit?.method ?? 'GET'; -} - -export function getRequestBody( - input: RequestInfo | URL, - init?: RequestInit -): BodyInit | null | undefined { - if (init?.body !== undefined) { - return init.body; - } - - if (typeof Request !== 'undefined' && input instanceof Request) { - return input.body; - } - - return undefined; -} - -export function summarizeRequestBody(body: BodyInit | null | undefined): RequestBodySummary { - if (body == null) { - return { kind: 'empty' }; - } - - if (typeof body === 'string') { - return { kind: 'string', size: body.length }; - } - - if (body instanceof Blob) { - return { kind: 'blob', size: body.size }; - } - - if (body instanceof URLSearchParams) { - return { kind: 'urlsearchparams', size: body.toString().length }; - } - - if (body instanceof FormData) { - return { kind: 'formdata' }; - } - - if (body instanceof ArrayBuffer) { - return { kind: 'arraybuffer', size: body.byteLength }; - } - - if (ArrayBuffer.isView(body)) { - return { kind: body.constructor.name, size: body.byteLength }; - } - - return { kind: typeof body }; -} - -export function formatDiagnosticErrorMessage(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - - return message.includes('Failed to fetch') ? `${message} (check CORS?)` : message; -} - -export function extractJsonRpcMethods(body: BodyInit | null | undefined): string[] | undefined { - if (typeof body !== 'string') { - return undefined; - } - - try { - const parsed = JSON.parse(body); - const messages = Array.isArray(parsed) ? parsed : [parsed]; - const methods = messages - .map((message: Record) => - typeof message?.method === 'string' ? (message.method as string) : undefined - ) - .filter((method: string | undefined): method is string => Boolean(method)); - - return methods.length > 0 ? methods : undefined; - } catch { - return undefined; - } -} diff --git a/tools/server/webui/src/lib/utils/sanitize.ts b/tools/server/webui/src/lib/utils/sanitize.ts deleted file mode 100644 index 6078ecdf7..000000000 --- a/tools/server/webui/src/lib/utils/sanitize.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - KEY_VALUE_PAIR_KEY_MAX_LENGTH, - KEY_VALUE_PAIR_VALUE_MAX_LENGTH, - KEY_VALUE_PAIR_UNSAFE_KEY_RE, - KEY_VALUE_PAIR_UNSAFE_VALUE_RE -} from '$lib/constants'; - -/** - * Strip control characters unsafe in identifier/header-name contexts and cap length. - * Removes all C0 controls (including TAB) and DEL. - */ -export function sanitizeKeyValuePairKey(raw: string): string { - return raw.replace(KEY_VALUE_PAIR_UNSAFE_KEY_RE, '').slice(0, KEY_VALUE_PAIR_KEY_MAX_LENGTH); -} - -/** - * Strip control characters that enable header injection; allow TAB; cap length. - * Removes null bytes, CR/LF and other C0/DEL controls while keeping TAB (\x09), - * which is a valid header-value continuation character per RFC 7230. - */ -export function sanitizeKeyValuePairValue(raw: string): string { - return raw.replace(KEY_VALUE_PAIR_UNSAFE_VALUE_RE, '').slice(0, KEY_VALUE_PAIR_VALUE_MAX_LENGTH); -} diff --git a/tools/server/webui/src/lib/utils/svg-to-png.ts b/tools/server/webui/src/lib/utils/svg-to-png.ts deleted file mode 100644 index d5a7f7d83..000000000 --- a/tools/server/webui/src/lib/utils/svg-to-png.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { MimeTypeImage } from '$lib/enums'; - -/** - * Convert an SVG base64 data URL to a PNG data URL - * @param base64UrlSvg - The SVG base64 data URL to convert - * @param backgroundColor - Background color for the PNG (default: 'white') - * @returns Promise resolving to PNG data URL - */ -export function svgBase64UrlToPngDataURL( - base64UrlSvg: string, - backgroundColor: string = 'white' -): Promise { - return new Promise((resolve, reject) => { - try { - const img = new Image(); - - img.onload = () => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - - if (!ctx) { - reject(new Error('Failed to get 2D canvas context.')); - return; - } - - const targetWidth = img.naturalWidth || 300; - const targetHeight = img.naturalHeight || 300; - - canvas.width = targetWidth; - canvas.height = targetHeight; - - if (backgroundColor) { - ctx.fillStyle = backgroundColor; - ctx.fillRect(0, 0, canvas.width, canvas.height); - } - ctx.drawImage(img, 0, 0, targetWidth, targetHeight); - - resolve(canvas.toDataURL(MimeTypeImage.PNG)); - }; - - img.onerror = () => { - reject(new Error('Failed to load SVG image. Ensure the SVG data is valid.')); - }; - - img.src = base64UrlSvg; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const errorMessage = `Error converting SVG to PNG: ${message}`; - console.error(errorMessage, error); - reject(new Error(errorMessage)); - } - }); -} - -/** - * Check if a file is an SVG based on its MIME type - * @param file - The file to check - * @returns True if the file is an SVG - */ -export function isSvgFile(file: File): boolean { - return file.type === MimeTypeImage.SVG; -} - -/** - * Check if a MIME type represents an SVG - * @param mimeType - The MIME type to check - * @returns True if the MIME type is image/svg+xml - */ -export function isSvgMimeType(mimeType: string): boolean { - return mimeType === MimeTypeImage.SVG; -} diff --git a/tools/server/webui/src/lib/utils/syntax-highlight-language.ts b/tools/server/webui/src/lib/utils/syntax-highlight-language.ts deleted file mode 100644 index 538429182..000000000 --- a/tools/server/webui/src/lib/utils/syntax-highlight-language.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Maps file extensions to highlight.js language identifiers - */ -export function getLanguageFromFilename(filename: string): string { - const extension = filename.toLowerCase().substring(filename.lastIndexOf('.')); - - switch (extension) { - // JavaScript / TypeScript - case '.js': - case '.mjs': - case '.cjs': - return 'javascript'; - case '.ts': - case '.mts': - case '.cts': - return 'typescript'; - case '.jsx': - return 'javascript'; - case '.tsx': - return 'typescript'; - - // Web - case '.html': - case '.htm': - return 'html'; - case '.css': - return 'css'; - case '.scss': - return 'scss'; - case '.less': - return 'less'; - case '.vue': - return 'html'; - case '.svelte': - return 'html'; - - // Data formats - case '.json': - return 'json'; - case '.xml': - return 'xml'; - case '.yaml': - case '.yml': - return 'yaml'; - case '.toml': - return 'ini'; - case '.csv': - return 'plaintext'; - - // Programming languages - case '.py': - return 'python'; - case '.java': - return 'java'; - case '.kt': - case '.kts': - return 'kotlin'; - case '.scala': - return 'scala'; - case '.cpp': - case '.cc': - case '.cxx': - case '.c++': - return 'cpp'; - case '.c': - return 'c'; - case '.h': - case '.hpp': - return 'cpp'; - case '.cs': - return 'csharp'; - case '.go': - return 'go'; - case '.rs': - return 'rust'; - case '.rb': - return 'ruby'; - case '.php': - return 'php'; - case '.swift': - return 'swift'; - case '.dart': - return 'dart'; - case '.r': - return 'r'; - case '.lua': - return 'lua'; - case '.pl': - case '.pm': - return 'perl'; - - // Shell - case '.sh': - case '.bash': - case '.zsh': - return 'bash'; - case '.bat': - case '.cmd': - return 'dos'; - case '.ps1': - return 'powershell'; - - // Database - case '.sql': - return 'sql'; - - // Markup / Documentation - case '.md': - case '.markdown': - return 'markdown'; - case '.tex': - case '.latex': - return 'latex'; - case '.adoc': - case '.asciidoc': - return 'asciidoc'; - - // Config - case '.ini': - case '.cfg': - case '.conf': - return 'ini'; - case '.dockerfile': - return 'dockerfile'; - case '.nginx': - return 'nginx'; - - // Other - case '.graphql': - case '.gql': - return 'graphql'; - case '.proto': - return 'protobuf'; - case '.diff': - case '.patch': - return 'diff'; - case '.log': - return 'plaintext'; - case '.txt': - return 'plaintext'; - - default: - return 'plaintext'; - } -} diff --git a/tools/server/webui/src/lib/utils/text-files.ts b/tools/server/webui/src/lib/utils/text-files.ts deleted file mode 100644 index 3f7a55ebc..000000000 --- a/tools/server/webui/src/lib/utils/text-files.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Text file processing utilities - * Handles text file detection, reading, and validation - */ - -import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants'; -import type { BinaryDetectionOptions } from '$lib/types'; -import { FileExtensionText } from '$lib/enums'; - -/** - * Check if a filename indicates a text file based on its extension - * @param filename - The filename to check - * @returns True if the filename has a recognized text file extension - */ -export function isTextFileByName(filename: string): boolean { - const textExtensions = Object.values(FileExtensionText); - - return textExtensions.some((ext: FileExtensionText) => filename.toLowerCase().endsWith(ext)); -} - -/** - * Read a file's content as text - * @param file - The file to read - * @returns Promise resolving to the file's text content - */ -export async function readFileAsText(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = (event) => { - if (event.target?.result !== null && event.target?.result !== undefined) { - resolve(event.target.result as string); - } else { - reject(new Error('Failed to read file')); - } - }; - - reader.onerror = () => reject(new Error('File reading error')); - - reader.readAsText(file); - }); -} - -/** - * Heuristic check to determine if content is likely from a text file - * Detects binary files by counting suspicious characters and null bytes - * @param content - The file content to analyze - * @param options - Optional configuration for detection parameters - * @returns True if the content appears to be text-based - */ -export function isLikelyTextFile( - content: string, - options: Partial = {} -): boolean { - if (!content) return true; - - const config = { ...DEFAULT_BINARY_DETECTION_OPTIONS, ...options }; - const sample = content.substring(0, config.prefixLength); - - let nullCount = 0; - let suspiciousControlCount = 0; - - for (let i = 0; i < sample.length; i++) { - const charCode = sample.charCodeAt(i); - - // Count null bytes - these are strong indicators of binary files - if (charCode === 0) { - nullCount++; - - continue; - } - - // Count suspicious control characters - // Allow common whitespace characters: tab (9), newline (10), carriage return (13) - if (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13) { - // Count most suspicious control characters - if (charCode < 8 || (charCode > 13 && charCode < 27)) { - suspiciousControlCount++; - } - } - - // Count replacement characters (indicates encoding issues) - if (charCode === 0xfffd) { - suspiciousControlCount++; - } - } - - // Reject if too many null bytes - if (nullCount > config.maxAbsoluteNullBytes) return false; - - // Reject if too many suspicious characters - if (suspiciousControlCount / sample.length > config.suspiciousCharThresholdRatio) return false; - - return true; -} diff --git a/tools/server/webui/src/lib/utils/text.ts b/tools/server/webui/src/lib/utils/text.ts deleted file mode 100644 index a2a4a1b57..000000000 --- a/tools/server/webui/src/lib/utils/text.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NEWLINE_SEPARATOR } from '$lib/constants'; - -/** - * Returns a shortened preview of the provided content capped at the given length. - * Appends an ellipsis when the content exceeds the maximum. - */ -export function getPreviewText(content: string, max = 150): string { - return content.length > max ? content.slice(0, max) + '...' : content; -} - -/** - * Generates a single-line title from a potentially multi-line prompt. - * Uses the first non-empty line if `useFirstLine` is true. - */ -export function generateConversationTitle(content: string, useFirstLine: boolean = false): string { - if (useFirstLine) { - const firstLine = content.split(NEWLINE_SEPARATOR).find((line) => line.trim().length > 0); - return firstLine ? firstLine.trim() : content.trim(); - } - - return content.trim(); -} diff --git a/tools/server/webui/src/lib/utils/uri-template.ts b/tools/server/webui/src/lib/utils/uri-template.ts deleted file mode 100644 index 7665c98c9..000000000 --- a/tools/server/webui/src/lib/utils/uri-template.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { - TEMPLATE_EXPRESSION_REGEX, - URI_SCHEME_SEPARATOR, - URI_TEMPLATE_OPERATORS, - URI_TEMPLATE_SEPARATORS, - VARIABLE_EXPLODE_MODIFIER_REGEX, - VARIABLE_PREFIX_MODIFIER_REGEX, - LEADING_SLASHES_REGEX -} from '../constants'; - -/** - * Normalize a resource URI for comparison. - * - * URI template expansion (especially with path operators like {/var}) - * can produce URIs that differ from listed resource URIs in slash placement. - * For example, the template `svelte://{/slug*}.md` with slug="svelte/$effect" - * expands to `svelte:///svelte/$effect.md`, while the listed resource URI is - * `svelte://svelte/$effect.md`. - * - * This function strips extra leading slashes after the scheme to normalize - * both forms to the same string for comparison purposes. - * - * @param uri - The URI to normalize - * @returns Normalized URI string - */ -export function normalizeResourceUri(uri: string): string { - const schemeEnd = uri.indexOf(URI_SCHEME_SEPARATOR); - if (schemeEnd === -1) return uri; - - const scheme = uri.substring(0, schemeEnd); - const rest = uri - .substring(schemeEnd + URI_SCHEME_SEPARATOR.length) - .replace(LEADING_SLASHES_REGEX, ''); - - return `${scheme}${URI_SCHEME_SEPARATOR}${rest}`; -} - -/** - * A parsed variable from a URI template expression. - */ -export interface UriTemplateVariable { - /** Variable name */ - name: string; - /** Operator prefix (+, #, /, etc.) or empty string */ - operator: string; -} - -/** - * Extract all variable names from a URI template string. - * - * @param template - URI template string (RFC 6570) - * @returns Array of unique variable descriptors - * - * @example - * ```ts - * extractTemplateVariables("file:///{path}") - * // => [{ name: "path", operator: "" }] - * - * extractTemplateVariables("db://{schema}/{table}") - * // => [{ name: "schema", operator: "" }, { name: "table", operator: "" }] - * ``` - */ -export function extractTemplateVariables(template: string): UriTemplateVariable[] { - const variables: UriTemplateVariable[] = []; - const seen = new Set(); - - let match; - TEMPLATE_EXPRESSION_REGEX.lastIndex = 0; - - while ((match = TEMPLATE_EXPRESSION_REGEX.exec(template)) !== null) { - const operator = match[1] || ''; - const varList = match[2]; - - // RFC 6570 allows comma-separated variable lists: {x,y,z} - for (const varSpec of varList.split(',')) { - // Strip explode modifier (*) and prefix modifier (:N) - const name = varSpec - .replace(VARIABLE_EXPLODE_MODIFIER_REGEX, '') - .replace(VARIABLE_PREFIX_MODIFIER_REGEX, '') - .trim(); - - if (name && !seen.has(name)) { - seen.add(name); - variables.push({ name, operator }); - } - } - } - - return variables; -} - -/** - * Expand a URI template with the given variable values. - * Implements a simplified RFC 6570 Level 2 expansion. - * - * @param template - URI template string - * @param values - Map of variable name to value - * @returns Expanded URI string - * - * @example - * ```ts - * expandTemplate("file:///{path}", { path: "src/main.rs" }) - * // => "file:///src/main.rs" - * ``` - */ -export function expandTemplate(template: string, values: Record): string { - TEMPLATE_EXPRESSION_REGEX.lastIndex = 0; - - return template.replace( - TEMPLATE_EXPRESSION_REGEX, - (_match, operator: string, varList: string) => { - const varNames = varList - .split(',') - .map((v: string) => - v - .replace(VARIABLE_EXPLODE_MODIFIER_REGEX, '') - .replace(VARIABLE_PREFIX_MODIFIER_REGEX, '') - .trim() - ); - - const expandedParts = varNames - .map((name: string) => values[name] ?? '') - .filter((v: string) => v !== ''); - - if (expandedParts.length === 0) return ''; - - switch (operator) { - case URI_TEMPLATE_OPERATORS.RESERVED: - // Reserved expansion: no encoding - return expandedParts.join(URI_TEMPLATE_SEPARATORS.COMMA); - case URI_TEMPLATE_OPERATORS.FRAGMENT: - // Fragment expansion - return ( - URI_TEMPLATE_OPERATORS.FRAGMENT + expandedParts.join(URI_TEMPLATE_SEPARATORS.COMMA) - ); - case URI_TEMPLATE_OPERATORS.PATH_SEGMENT: - // Path segments - return URI_TEMPLATE_SEPARATORS.SLASH + expandedParts.join(URI_TEMPLATE_SEPARATORS.SLASH); - case URI_TEMPLATE_OPERATORS.LABEL: - // Label expansion - return ( - URI_TEMPLATE_SEPARATORS.PERIOD + expandedParts.join(URI_TEMPLATE_SEPARATORS.PERIOD) - ); - case URI_TEMPLATE_OPERATORS.PATH_PARAM: - // Path-style parameters - return varNames - .filter((_: string, i: number) => expandedParts[i]) - .map( - (name: string, i: number) => - `${URI_TEMPLATE_SEPARATORS.SEMICOLON}${name}=${expandedParts[i]}` - ) - .join(''); - case URI_TEMPLATE_OPERATORS.FORM_QUERY: - // Form-style query - return ( - URI_TEMPLATE_SEPARATORS.QUERY_PREFIX + - varNames - .filter((_: string, i: number) => expandedParts[i]) - .map( - (name: string, i: number) => - `${encodeURIComponent(name)}=${encodeURIComponent(expandedParts[i])}` - ) - .join(URI_TEMPLATE_SEPARATORS.COMMA) - ); - case URI_TEMPLATE_OPERATORS.FORM_CONTINUATION: - // Form-style query continuation - return ( - URI_TEMPLATE_SEPARATORS.QUERY_CONTINUATION + - varNames - .filter((_: string, i: number) => expandedParts[i]) - .map( - (name: string, i: number) => - `${encodeURIComponent(name)}=${encodeURIComponent(expandedParts[i])}` - ) - .join(URI_TEMPLATE_SEPARATORS.COMMA) - ); - default: - // Simple string expansion (default operator) - return expandedParts - .map((v: string) => encodeURIComponent(v)) - .join(URI_TEMPLATE_SEPARATORS.COMMA); - } - } - ); -} - -/** - * Check whether all required variables in a template have been provided. - * - * @param template - URI template string - * @param values - Map of variable name to value - * @returns true if all variables have non-empty values - */ -export function isTemplateComplete(template: string, values: Record): boolean { - const variables = extractTemplateVariables(template); - - return variables.every((v) => (values[v.name] ?? '').trim() !== ''); -} diff --git a/tools/server/webui/src/lib/utils/url.ts b/tools/server/webui/src/lib/utils/url.ts deleted file mode 100644 index e8b78f7bd..000000000 --- a/tools/server/webui/src/lib/utils/url.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { TWO_PART_PUBLIC_SUFFIXES, WILDCARD_PUBLIC_SUFFIXES } from '$lib/constants'; -import { UrlProtocol } from '$lib/enums'; - -/** - * Check whether a hostname looks like an IPv4 or IPv6 address. - */ -function isIpAddress(hostname: string): boolean { - if (hostname.includes(':')) return true; - - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return true; - - return false; -} - -/** - * Extract the registrable root domain from a URL. - * - * @example - * 'mcp.example.com' -> 'example.com' - * 'www.example.co.uk' -> 'example.co.uk' - * 'bar.foo.nom.br' -> 'bar.foo.nom.br' - * '192.168.1.1' -> null - * 'localhost' -> null - */ -export function extractRootDomain(url: URL): string | null { - const hostname = url.hostname.toLowerCase(); - if (!hostname || isIpAddress(hostname)) return null; - - const parts = hostname.split('.'); - - if (parts.length < 2) return null; - - if (parts.length >= 3) { - const suffix2 = `${parts[parts.length - 2]}.${parts[parts.length - 1]}`; - - if (TWO_PART_PUBLIC_SUFFIXES.has(suffix2)) { - return parts.slice(-3).join('.'); - } - } - - for (let i = 2; i <= parts.length; i++) { - const candidate = parts.slice(-i).join('.'); - - if (WILDCARD_PUBLIC_SUFFIXES.has(candidate)) { - if (parts.length === i + 1) { - return hostname; - } - - return parts.slice(-(i + 2)).join('.'); - } - } - - return parts.slice(-2).join('.'); -} - -/** - * Sanitize an external URL string for safe use in an ``. - * Only allows http: and https: schemes. Returns `null` for anything else. - */ -export function sanitizeExternalUrl(raw: string): string | null { - try { - const url = new URL(raw); - - if (url.protocol !== UrlProtocol.HTTP && url.protocol !== UrlProtocol.HTTPS) { - return null; - } - - return url.href; - } catch { - return null; - } -} diff --git a/tools/server/webui/src/lib/utils/uuid.ts b/tools/server/webui/src/lib/utils/uuid.ts deleted file mode 100644 index 29c20b310..000000000 --- a/tools/server/webui/src/lib/utils/uuid.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function uuid(): string { - return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).substring(2); -} diff --git a/tools/server/webui/src/lib/utils/viewport.ts b/tools/server/webui/src/lib/utils/viewport.ts deleted file mode 100644 index 9e9b7aff3..000000000 --- a/tools/server/webui/src/lib/utils/viewport.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Check if an element is within the current viewport. - */ -export function isElementInViewport(node: HTMLElement): boolean { - const rect = node.getBoundingClientRect(); - return ( - rect.top < window.innerHeight && - rect.bottom > 0 && - rect.left < window.innerWidth && - rect.right > 0 - ); -} diff --git a/tools/server/webui/src/lib/utils/webp-to-png.ts b/tools/server/webui/src/lib/utils/webp-to-png.ts deleted file mode 100644 index ea5183802..000000000 --- a/tools/server/webui/src/lib/utils/webp-to-png.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { FileExtensionImage, MimeTypeImage } from '$lib/enums'; - -/** - * Convert a WebP base64 data URL to a PNG data URL - * @param base64UrlWebp - The WebP base64 data URL to convert - * @param backgroundColor - Background color for the PNG (default: 'white') - * @returns Promise resolving to PNG data URL - */ -export function webpBase64UrlToPngDataURL( - base64UrlWebp: string, - backgroundColor: string = 'white' -): Promise { - return new Promise((resolve, reject) => { - try { - const img = new Image(); - - img.onload = () => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - - if (!ctx) { - reject(new Error('Failed to get 2D canvas context.')); - return; - } - - const targetWidth = img.naturalWidth || 300; - const targetHeight = img.naturalHeight || 300; - - canvas.width = targetWidth; - canvas.height = targetHeight; - - if (backgroundColor) { - ctx.fillStyle = backgroundColor; - ctx.fillRect(0, 0, canvas.width, canvas.height); - } - ctx.drawImage(img, 0, 0, targetWidth, targetHeight); - - resolve(canvas.toDataURL(MimeTypeImage.PNG)); - }; - - img.onerror = () => { - reject(new Error('Failed to load WebP image. Ensure the WebP data is valid.')); - }; - - img.src = base64UrlWebp; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const errorMessage = `Error converting WebP to PNG: ${message}`; - console.error(errorMessage, error); - reject(new Error(errorMessage)); - } - }); -} - -/** - * Check if a file is a WebP based on its MIME type - * @param file - The file to check - * @returns True if the file is a WebP - */ -export function isWebpFile(file: File): boolean { - return ( - file.type === MimeTypeImage.WEBP || file.name.toLowerCase().endsWith(FileExtensionImage.WEBP) - ); -} - -/** - * Check if a MIME type represents a WebP - * @param mimeType - The MIME type to check - * @returns True if the MIME type is image/webp - */ -export function isWebpMimeType(mimeType: string): boolean { - return mimeType === MimeTypeImage.WEBP; -} diff --git a/tools/server/webui/src/routes/(chat)/+layout.svelte b/tools/server/webui/src/routes/(chat)/+layout.svelte deleted file mode 100644 index 37aa03582..000000000 --- a/tools/server/webui/src/routes/(chat)/+layout.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - - -{@render children?.()} diff --git a/tools/server/webui/src/routes/(chat)/+page.svelte b/tools/server/webui/src/routes/(chat)/+page.svelte deleted file mode 100644 index c272b438e..000000000 --- a/tools/server/webui/src/routes/(chat)/+page.svelte +++ /dev/null @@ -1,103 +0,0 @@ - - - - {APP_NAME} - - - diff --git a/tools/server/webui/src/routes/(chat)/+page.ts b/tools/server/webui/src/routes/(chat)/+page.ts deleted file mode 100644 index 7905af6b5..000000000 --- a/tools/server/webui/src/routes/(chat)/+page.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { PageLoad } from './$types'; -import { validateApiKey } from '$lib/utils'; - -export const load: PageLoad = async ({ fetch }) => { - await validateApiKey(fetch); -}; diff --git a/tools/server/webui/src/routes/(chat)/chat/[id]/+page.svelte b/tools/server/webui/src/routes/(chat)/chat/[id]/+page.svelte deleted file mode 100644 index e31d4443e..000000000 --- a/tools/server/webui/src/routes/(chat)/chat/[id]/+page.svelte +++ /dev/null @@ -1,135 +0,0 @@ - - - - {activeConversation()?.name || 'Chat'} - {APP_NAME} - - - diff --git a/tools/server/webui/src/routes/(chat)/chat/[id]/+page.ts b/tools/server/webui/src/routes/(chat)/chat/[id]/+page.ts deleted file mode 100644 index 7905af6b5..000000000 --- a/tools/server/webui/src/routes/(chat)/chat/[id]/+page.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { PageLoad } from './$types'; -import { validateApiKey } from '$lib/utils'; - -export const load: PageLoad = async ({ fetch }) => { - await validateApiKey(fetch); -}; diff --git a/tools/server/webui/src/routes/+error.svelte b/tools/server/webui/src/routes/+error.svelte deleted file mode 100644 index 4f8251d86..000000000 --- a/tools/server/webui/src/routes/+error.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - - - Error {status} - WebUI - - -{#if isApiKeyError} - -{:else} - -
          -
          -
          -
          - - - -
          -

          Error {status}

          -

          - {error?.message || 'Something went wrong'} -

          -
          - -
          -
          -{/if} diff --git a/tools/server/webui/src/routes/+layout.svelte b/tools/server/webui/src/routes/+layout.svelte deleted file mode 100644 index ce0014992..000000000 --- a/tools/server/webui/src/routes/+layout.svelte +++ /dev/null @@ -1,269 +0,0 @@ - - - - - - - - - - -
          - - - - - {#if !(alwaysShowSidebarOnDesktop && isDesktop) && !(panelNav.isSettingsRoute && !isDesktop)} - {#if mounted} -
          - -
          - {/if} - {/if} - - {#if isDesktop && !alwaysShowSidebarOnDesktop} - { - if (chatSidebar?.activateSearchMode) { - chatSidebar.activateSearchMode(); - } - - sidebarOpen = true; - }} - /> - {/if} - - - {@render children?.()} - -
          -
          -
          - - diff --git a/tools/server/webui/src/routes/mcp-servers/+page.svelte b/tools/server/webui/src/routes/mcp-servers/+page.svelte deleted file mode 100644 index 1758134c3..000000000 --- a/tools/server/webui/src/routes/mcp-servers/+page.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/tools/server/webui/src/routes/settings/+layout.svelte b/tools/server/webui/src/routes/settings/+layout.svelte deleted file mode 100644 index b3b3d30e0..000000000 --- a/tools/server/webui/src/routes/settings/+layout.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - -
          -
          - -
          - -
          - {@render children?.()} -
          -
          diff --git a/tools/server/webui/src/routes/settings/[[section]]/+page.svelte b/tools/server/webui/src/routes/settings/[[section]]/+page.svelte deleted file mode 100644 index 22e727f17..000000000 --- a/tools/server/webui/src/routes/settings/[[section]]/+page.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - -).section} /> diff --git a/tools/server/webui/src/styles/katex-custom.scss b/tools/server/webui/src/styles/katex-custom.scss deleted file mode 100644 index 9c8b96ed5..000000000 --- a/tools/server/webui/src/styles/katex-custom.scss +++ /dev/null @@ -1,13 +0,0 @@ -// Override KaTeX SCSS variables to disable ttf and woff fonts -// Only use woff2 format which is embedded in the bundle -$use-woff2: true; -$use-woff: false; -$use-ttf: false; - -// Use Vite alias for font folder -$font-folder: 'katex-fonts'; - -// Import KaTeX SCSS with overridden variables -// Note: @import is deprecated but required because KaTeX uses @import internally -// The deprecation warnings are from KaTeX's code and cannot be avoided -@import 'katex/src/styles/katex.scss'; diff --git a/tools/server/webui/static/favicon.svg b/tools/server/webui/static/favicon.svg deleted file mode 100644 index a7ae13691..000000000 --- a/tools/server/webui/static/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/tools/server/webui/static/loading.html b/tools/server/webui/static/loading.html deleted file mode 100644 index c3fd19a0f..000000000 --- a/tools/server/webui/static/loading.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - -
          - The model is loading. Please wait.
          - The user interface will appear soon. -
          - - diff --git a/tools/server/webui/svelte.config.js b/tools/server/webui/svelte.config.js deleted file mode 100644 index 7a7f50536..000000000 --- a/tools/server/webui/svelte.config.js +++ /dev/null @@ -1,37 +0,0 @@ -import { mdsvex } from 'mdsvex'; -import adapter from '@sveltejs/adapter-static'; -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; - -/** @type {import('@sveltejs/kit').Config} */ -const config = { - // Consult https://svelte.dev/docs/kit/integrations - // for more information about preprocessors - preprocess: [vitePreprocess(), mdsvex()], - - kit: { - paths: { - relative: true - }, - router: { type: 'hash' }, - adapter: adapter({ - pages: '../public', - assets: '../public', - fallback: 'index.html', - precompress: false, - strict: true - }), - output: { - bundleStrategy: 'single' - }, - alias: { - $styles: 'src/styles' - }, - version: { - name: 'llama-ui' - } - }, - - extensions: ['.svelte', '.svx'] -}; - -export default config; diff --git a/tools/server/webui/tests/client/components/TestWrapper.svelte b/tools/server/webui/tests/client/components/TestWrapper.svelte deleted file mode 100644 index aeb7ff74c..000000000 --- a/tools/server/webui/tests/client/components/TestWrapper.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - diff --git a/tools/server/webui/tests/client/page.svelte.test.ts b/tools/server/webui/tests/client/page.svelte.test.ts deleted file mode 100644 index 6849beb27..000000000 --- a/tools/server/webui/tests/client/page.svelte.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { render } from 'vitest-browser-svelte'; -import TestWrapper from './components/TestWrapper.svelte'; - -describe('/+page.svelte', () => { - it('should render page without throwing', async () => { - // Basic smoke test - page should render without throwing errors - // API calls will fail in test environment but component should still mount - expect(() => render(TestWrapper)).not.toThrow(); - }); -}); diff --git a/tools/server/webui/tests/e2e/demo.test.ts b/tools/server/webui/tests/e2e/demo.test.ts deleted file mode 100644 index b7b4bac33..000000000 --- a/tools/server/webui/tests/e2e/demo.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('home page has expected h1', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('h1').first()).toBeVisible(); -}); diff --git a/tools/server/webui/tests/stories/ChatMessage.stories.svelte b/tools/server/webui/tests/stories/ChatMessage.stories.svelte deleted file mode 100644 index e640176a8..000000000 --- a/tools/server/webui/tests/stories/ChatMessage.stories.svelte +++ /dev/null @@ -1,207 +0,0 @@ - - - { - const { settingsStore } = await import('$lib/stores/settings.svelte'); - settingsStore.updateConfig('showRawOutputSwitch', false); - }} -/> - - { - const { settingsStore } = await import('$lib/stores/settings.svelte'); - settingsStore.updateConfig('showRawOutputSwitch', false); - }} -/> - - { - const { settingsStore } = await import('$lib/stores/settings.svelte'); - settingsStore.updateConfig('showRawOutputSwitch', false); - }} -/> - - { - const { settingsStore } = await import('$lib/stores/settings.svelte'); - settingsStore.updateConfig('showRawOutputSwitch', true); - }} -/> - - { - const { settingsStore } = await import('$lib/stores/settings.svelte'); - settingsStore.updateConfig('showRawOutputSwitch', false); - // Phase 1: Stream reasoning content in chunks - let reasoningText = - 'I need to think about this carefully. Let me break down the problem:\n\n1. The user is asking for help with something complex\n2. I should provide a thorough and helpful response\n3. I need to consider multiple approaches\n4. The best solution would be to explain step by step\n\nThis approach will ensure clarity and understanding.'; - - let reasoningChunk = 'I'; - let i = 0; - while (i < reasoningText.length) { - const chunkSize = Math.floor(Math.random() * 5) + 3; // Random 3-7 characters - const chunk = reasoningText.slice(i, i + chunkSize); - reasoningChunk += chunk; - - // Update the reactive state directly - streamingMessage.thinking = reasoningChunk; - - i += chunkSize; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - - const regularText = - "Based on my analysis, here's the solution:\n\n**Step 1:** First, we need to understand the requirements clearly.\n\n**Step 2:** Then we can implement the solution systematically.\n\n**Step 3:** Finally, we test and validate the results.\n\nThis approach ensures we cover all aspects of the problem effectively."; - - let contentChunk = ''; - i = 0; - - while (i < regularText.length) { - const chunkSize = Math.floor(Math.random() * 5) + 3; // Random 3-7 characters - const chunk = regularText.slice(i, i + chunkSize); - contentChunk += chunk; - - // Update the reactive state directly - streamingMessage.content = contentChunk; - - i += chunkSize; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - - streamingMessage.timestamp = Date.now(); - }} -> -
          - -
          -
          - - { - const { settingsStore } = await import('$lib/stores/settings.svelte'); - settingsStore.updateConfig('showRawOutputSwitch', false); - // Import the chat store to simulate loading state - const { chatStore } = await import('$lib/stores/chat.svelte'); - - // Set loading state to true to trigger the processing UI - chatStore.isLoading = true; - - // Simulate the processing state hook behavior - // This will show the "Generating..." text and parameter details - await new Promise((resolve) => setTimeout(resolve, 100)); - }} -/> diff --git a/tools/server/webui/tests/stories/ChatScreenForm.stories.svelte b/tools/server/webui/tests/stories/ChatScreenForm.stories.svelte deleted file mode 100644 index 4c1734345..000000000 --- a/tools/server/webui/tests/stories/ChatScreenForm.stories.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - - { - const textarea = await canvas.findByRole('textbox'); - const submitButton = await canvas.findByRole('button', { name: 'Send' }); - - // Expect the input to be focused after the component is mounted - await expect(textarea).toHaveFocus(); - - // Expect the submit button to be disabled - await expect(submitButton).toBeDisabled(); - - const text = 'What is the meaning of life?'; - - await userEvent.clear(textarea); - await userEvent.type(textarea, text); - - await expect(textarea).toHaveValue(text); - - const fileInput = document.querySelector('input[type="file"]'); - await expect(fileInput).not.toHaveAttribute('accept'); - }} -/> - - - - { - const jpgAttachment = canvas.getByAltText('1.jpg'); - const svgAttachment = canvas.getByAltText('hf-logo.svg'); - const pdfFileExtension = canvas.getByText('PDF'); - const pdfAttachment = canvas.getByText('example.pdf'); - const pdfSize = canvas.getByText('342.82 KB'); - - await expect(jpgAttachment).toBeInTheDocument(); - await expect(jpgAttachment).toHaveAttribute('src', jpgAsset); - - await expect(svgAttachment).toBeInTheDocument(); - await expect(svgAttachment).toHaveAttribute('src', svgAsset); - - await expect(pdfFileExtension).toBeInTheDocument(); - await expect(pdfAttachment).toBeInTheDocument(); - await expect(pdfSize).toBeInTheDocument(); - }} -/> diff --git a/tools/server/webui/tests/stories/Introduction.mdx b/tools/server/webui/tests/stories/Introduction.mdx deleted file mode 100644 index 55050cbd9..000000000 --- a/tools/server/webui/tests/stories/Introduction.mdx +++ /dev/null @@ -1,44 +0,0 @@ -import { Meta } from '@storybook/addon-docs/blocks'; - - - -# llama.cpp Web UI - -Welcome to the **llama-ui** component library! This Storybook showcases the components used in the modern web interface for the llama-server. - -## 🚀 About This Project - -WebUI is a modern web interface for the llama-server, built with SvelteKit and ShadCN UI. Features include: - -- **Real-time chat conversations** with AI assistants -- **Multi-conversation management** with persistent storage -- **Advanced parameter tuning** for model behavior -- **File upload support** for multimodal interactions -- **Responsive design** that works on desktop and mobile - -## 🎨 Design System - -The UI is built using: - -- **SvelteKit** - Modern web framework with excellent performance -- **Tailwind CSS** - Utility-first CSS framework for rapid styling -- **ShadCN/UI** - High-quality, accessible component library -- **Lucide Icons** - Beautiful, consistent icon set - -## 🔧 Development - -This Storybook serves as both documentation and a development environment for the UI components. Each story demonstrates: - -- **Component variations** - Different states and configurations -- **Interactive examples** - Live components you can interact with -- **Usage patterns** - How components work together -- **Styling consistency** - Unified design language - -## 🚀 Getting Started - -To explore the components: - -1. **Browse the sidebar** to see all available components -2. **Click on stories** to see different component states -3. **Use the controls panel** to interact with component props -4. **Check the docs tab** for detailed component information diff --git a/tools/server/webui/tests/stories/MarkdownContent.stories.svelte b/tools/server/webui/tests/stories/MarkdownContent.stories.svelte deleted file mode 100644 index 04f270a43..000000000 --- a/tools/server/webui/tests/stories/MarkdownContent.stories.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - - - - - - - - - - - - { - const { canvasElement } = context; - // Wait for component to render - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Find all links in the rendered content - const links = (canvasElement as HTMLElement).querySelectorAll( - 'a[href]' - ) as NodeListOf; - const linkList = Array.from(links) as HTMLAnchorElement[]; - - // Test that we have the expected number of links - expect(links.length).toBeGreaterThan(0); - - // Test each link for proper attributes - links.forEach((link: HTMLAnchorElement) => { - const href = link.getAttribute('href'); - - // Test that external links have proper security attributes - if (href && (href.startsWith('http://') || href.startsWith('https://'))) { - expect(link.getAttribute('target')).toBe('_blank'); - expect(link.getAttribute('rel')).toBe('noopener noreferrer'); - } - }); - - // Test specific links exist - const hugginFaceLink = linkList.find( - (link) => link.getAttribute('href') === 'https://huggingface.co' - ); - expect(hugginFaceLink).toBeTruthy(); - expect(hugginFaceLink?.textContent).toBe('Hugging Face Homepage'); - - const githubLink = linkList.find( - (link) => link.getAttribute('href') === 'https://github.com/ggml-org/llama.cpp' - ); - expect(githubLink).toBeTruthy(); - expect(githubLink?.textContent).toBe('GitHub Repository'); - - const openaiLink = linkList.find((link) => link.getAttribute('href') === 'https://openai.com'); - expect(openaiLink).toBeTruthy(); - expect(openaiLink?.textContent).toBe('OpenAI Website'); - - const googleLink = linkList.find( - (link) => link.getAttribute('href') === 'https://www.google.com' - ); - expect(googleLink).toBeTruthy(); - expect(googleLink?.textContent).toBe('Google Search'); - - // Test inline links (auto-linked URLs) - const exampleLink = linkList.find( - (link) => link.getAttribute('href') === 'https://example.com' - ); - expect(exampleLink).toBeTruthy(); - - const pythonDocsLink = linkList.find( - (link) => link.getAttribute('href') === 'https://docs.python.org' - ); - expect(pythonDocsLink).toBeTruthy(); - - console.log(`✅ URL Links test passed - Found ${links.length} links with proper attributes`); - }} -/> diff --git a/tools/server/webui/tests/stories/SidebarNavigation.stories.svelte b/tools/server/webui/tests/stories/SidebarNavigation.stories.svelte deleted file mode 100644 index f64ee4f9b..000000000 --- a/tools/server/webui/tests/stories/SidebarNavigation.stories.svelte +++ /dev/null @@ -1,109 +0,0 @@ - - - - - { - const { conversationsStore } = await import('$lib/stores/conversations.svelte'); - - waitFor(() => setTimeout(() => { - conversationsStore.conversations = mockConversations; - }, 0)); - }} -> - -
          - -
          -
          -
          - - { - const { conversationsStore } = await import('$lib/stores/conversations.svelte'); - - waitFor(() => setTimeout(() => { - conversationsStore.conversations = mockConversations; - }, 0)); - - const searchTrigger = screen.getByText('Search'); - userEvent.click(searchTrigger); - }} -> - -
          - -
          -
          -
          - - { - // Mock empty conversations store - const { conversationsStore } = await import('$lib/stores/conversations.svelte'); - conversationsStore.conversations = []; - }} -> - -
          - -
          -
          -
          diff --git a/tools/server/webui/tests/stories/fixtures/ai-tutorial.ts b/tools/server/webui/tests/stories/fixtures/ai-tutorial.ts deleted file mode 100644 index b3b1c2483..000000000 --- a/tools/server/webui/tests/stories/fixtures/ai-tutorial.ts +++ /dev/null @@ -1,164 +0,0 @@ -// AI Assistant Tutorial Response -export const AI_TUTORIAL_MD = String.raw` -# Building a Modern Chat Application with SvelteKit - -I'll help you create a **production-ready chat application** using SvelteKit, TypeScript, and WebSockets. This implementation includes real-time messaging, user authentication, and message persistence. - -## 🚀 Quick Start - -First, let's set up the project: - -${'```'}bash -npm create svelte@latest chat-app -cd chat-app -npm install -npm install socket.io socket.io-client -npm install @prisma/client prisma -npm run dev -${'```'} - -## 📁 Project Structure - -${'```'} -chat-app/ -├── src/ -│ ├── routes/ -│ │ ├── +layout.svelte -│ │ ├── +page.svelte -│ │ └── api/ -│ │ └── socket/+server.ts -│ ├── lib/ -│ │ ├── components/ -│ │ │ ├── ChatMessage.svelte -│ │ │ └── ChatInput.svelte -│ │ └── stores/ -│ │ └── chat.ts -│ └── app.html -├── prisma/ -│ └── schema.prisma -└── package.json -${'```'} - -## 💻 Implementation - -### WebSocket Server - -${'```'}typescript -// src/lib/server/socket.ts -import { Server } from 'socket.io'; -import type { ViteDevServer } from 'vite'; - -export function initializeSocketIO(server: ViteDevServer) { - const io = new Server(server.httpServer || server, { - cors: { - origin: process.env.ORIGIN || 'http://localhost:5173', - credentials: true - } - }); - - io.on('connection', (socket) => { - console.log('User connected:', socket.id); - - socket.on('message', async (data) => { - // Broadcast to all clients - io.emit('new-message', { - id: crypto.randomUUID(), - userId: socket.id, - content: data.content, - timestamp: new Date().toISOString() - }); - }); - - socket.on('disconnect', () => { - console.log('User disconnected:', socket.id); - }); - }); - - return io; -} -${'```'} - -### Client Store - -${'```'}typescript -// src/lib/stores/chat.ts -import { writable } from 'svelte/store'; -import io from 'socket.io-client'; - -export interface Message { - id: string; - userId: string; - content: string; - timestamp: string; -} - -function createChatStore() { - const { subscribe, update } = writable([]); - let socket: ReturnType; - - return { - subscribe, - connect: () => { - socket = io('http://localhost:5173'); - - socket.on('new-message', (message: Message) => { - update(messages => [...messages, message]); - }); - }, - sendMessage: (content: string) => { - if (socket && content.trim()) { - socket.emit('message', { content }); - } - } - }; -} - -export const chatStore = createChatStore(); -${'```'} - -## 🎯 Key Features - -✅ **Real-time messaging** with WebSockets -✅ **Message persistence** using Prisma + PostgreSQL -✅ **Type-safe** with TypeScript -✅ **Responsive UI** for all devices -✅ **Auto-reconnection** on connection loss - -## 📊 Performance Metrics - -| Metric | Value | -|--------|-------| -| **Message Latency** | < 50ms | -| **Concurrent Users** | 10,000+ | -| **Messages/Second** | 5,000+ | -| **Uptime** | 99.9% | - -## 🔧 Configuration - -### Environment Variables - -${'```'}env -DATABASE_URL="postgresql://user:password@localhost:5432/chat" -JWT_SECRET="your-secret-key" -REDIS_URL="redis://localhost:6379" -${'```'} - -## 🚢 Deployment - -Deploy to production using Docker: - -${'```'}dockerfile -FROM node:20-alpine -WORKDIR /app -COPY package*.json ./ -RUN npm ci --only=production -COPY . . -RUN npm run build -EXPOSE 3000 -CMD ["node", "build"] -${'```'} - ---- - -*Need help? Check the [documentation](https://kit.svelte.dev) or [open an issue](https://github.com/sveltejs/kit/issues)* -`; diff --git a/tools/server/webui/tests/stories/fixtures/api-docs.ts b/tools/server/webui/tests/stories/fixtures/api-docs.ts deleted file mode 100644 index 7b499956f..000000000 --- a/tools/server/webui/tests/stories/fixtures/api-docs.ts +++ /dev/null @@ -1,160 +0,0 @@ -// API Documentation -export const API_DOCS_MD = String.raw` -# REST API Documentation - -## 🔐 Authentication - -All API requests require authentication using **Bearer tokens**. Include your API key in the Authorization header: - -${'```'}http -GET /api/v1/users -Host: api.example.com -Authorization: Bearer YOUR_API_KEY -Content-Type: application/json -${'```'} - -## 📍 Endpoints - -### Users API - -#### **GET** /api/v1/users - -Retrieve a paginated list of users. - -**Query Parameters:** - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| page | integer | 1 | Page number | -| limit | integer | 20 | Items per page | -| sort | string | "created_at" | Sort field | -| order | string | "desc" | Sort order | - -**Response:** 200 OK - -${'```'}json -{ - "data": [ - { - "id": "usr_1234567890", - "email": "user@example.com", - "name": "John Doe", - "role": "admin", - "created_at": "2024-01-15T10:30:00Z" - } - ], - "pagination": { - "page": 1, - "limit": 20, - "total": 156, - "pages": 8 - } -} -${'```'} - -#### **POST** /api/v1/users - -Create a new user account. - -**Request Body:** - -${'```'}json -{ - "email": "newuser@example.com", - "password": "SecurePassword123!", - "name": "Jane Smith", - "role": "user" -} -${'```'} - -**Response:** 201 Created - -${'```'}json -{ - "id": "usr_9876543210", - "email": "newuser@example.com", - "name": "Jane Smith", - "role": "user", - "created_at": "2024-01-21T09:15:00Z" -} -${'```'} - -### Error Responses - -The API returns errors in a consistent format: - -${'```'}json -{ - "error": { - "code": "VALIDATION_ERROR", - "message": "Invalid request parameters", - "details": [ - { - "field": "email", - "message": "Email format is invalid" - } - ] - } -} -${'```'} - -### Rate Limiting - -| Tier | Requests/Hour | Burst | -|------|--------------|-------| -| **Free** | 1,000 | 100 | -| **Pro** | 10,000 | 500 | -| **Enterprise** | Unlimited | - | - -**Headers:** -- X-RateLimit-Limit -- X-RateLimit-Remaining -- X-RateLimit-Reset - -### Webhooks - -Configure webhooks to receive real-time events: - -${'```'}javascript -// Webhook payload -{ - "event": "user.created", - "timestamp": "2024-01-21T09:15:00Z", - "data": { - "id": "usr_9876543210", - "email": "newuser@example.com" - }, - "signature": "sha256=abcd1234..." -} -${'```'} - -### SDK Examples - -**JavaScript/TypeScript:** - -${'```'}typescript -import { ApiClient } from '@example/api-sdk'; - -const client = new ApiClient({ - apiKey: process.env.API_KEY -}); - -const users = await client.users.list({ - page: 1, - limit: 20 -}); -${'```'} - -**Python:** - -${'```'}python -from example_api import Client - -client = Client(api_key=os.environ['API_KEY']) -users = client.users.list(page=1, limit=20) -${'```'} - ---- - -📚 [Full API Reference](https://api.example.com/docs) | 💬 [Support](https://support.example.com) -`; diff --git a/tools/server/webui/tests/stories/fixtures/assets/1.jpg b/tools/server/webui/tests/stories/fixtures/assets/1.jpg deleted file mode 100644 index 8348e3878..000000000 Binary files a/tools/server/webui/tests/stories/fixtures/assets/1.jpg and /dev/null differ diff --git a/tools/server/webui/tests/stories/fixtures/assets/beautiful-flowers-lotus.webp b/tools/server/webui/tests/stories/fixtures/assets/beautiful-flowers-lotus.webp deleted file mode 100644 index 6efcffc3b..000000000 Binary files a/tools/server/webui/tests/stories/fixtures/assets/beautiful-flowers-lotus.webp and /dev/null differ diff --git a/tools/server/webui/tests/stories/fixtures/assets/example.pdf b/tools/server/webui/tests/stories/fixtures/assets/example.pdf deleted file mode 100644 index 915d30150..000000000 Binary files a/tools/server/webui/tests/stories/fixtures/assets/example.pdf and /dev/null differ diff --git a/tools/server/webui/tests/stories/fixtures/assets/hf-logo.svg b/tools/server/webui/tests/stories/fixtures/assets/hf-logo.svg deleted file mode 100644 index d55ea22a2..000000000 --- a/tools/server/webui/tests/stories/fixtures/assets/hf-logo.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/tools/server/webui/tests/stories/fixtures/blog-post.ts b/tools/server/webui/tests/stories/fixtures/blog-post.ts deleted file mode 100644 index 3eb2ed758..000000000 --- a/tools/server/webui/tests/stories/fixtures/blog-post.ts +++ /dev/null @@ -1,125 +0,0 @@ -// Blog Post Content -export const BLOG_POST_MD = String.raw` -# Understanding Rust's Ownership System - -*Published on March 15, 2024 • 8 min read* - -Rust's ownership system is one of its most distinctive features, enabling memory safety without garbage collection. In this post, we'll explore how ownership works and why it's revolutionary for systems programming. - -## What is Ownership? - -Ownership is a set of rules that governs how Rust manages memory. These rules are checked at compile time, ensuring memory safety without runtime overhead. - -### The Three Rules of Ownership - -1. **Each value has a single owner** -2. **There can only be one owner at a time** -3. **When the owner goes out of scope, the value is dropped** - -## Memory Management Without GC - -Traditional approaches to memory management: - -- **Manual management** (C/C++): Error-prone, leads to bugs -- **Garbage collection** (Java, Python): Runtime overhead -- **Ownership** (Rust): Compile-time safety, zero runtime cost - -## Basic Examples - -### Variable Scope - -${'```'}rust -fn main() { - let s = String::from("hello"); // s comes into scope - - // s is valid here - println!("{}", s); - -} // s goes out of scope and is dropped -${'```'} - -### Move Semantics - -${'```'}rust -fn main() { - let s1 = String::from("hello"); - let s2 = s1; // s1 is moved to s2 - - // println!("{}", s1); // ❌ ERROR: s1 is no longer valid - println!("{}", s2); // ✅ OK: s2 owns the string -} -${'```'} - -## Borrowing and References - -Instead of transferring ownership, you can **borrow** values: - -### Immutable References - -${'```'}rust -fn calculate_length(s: &String) -> usize { - s.len() // s is a reference, doesn't own the String -} - -fn main() { - let s1 = String::from("hello"); - let len = calculate_length(&s1); // Borrow s1 - println!("Length of '{}' is {}", s1, len); // s1 still valid -} -${'```'} - -### Mutable References - -${'```'}rust -fn main() { - let mut s = String::from("hello"); - - let r1 = &mut s; - r1.push_str(", world"); - println!("{}", r1); - - // let r2 = &mut s; // ❌ ERROR: cannot borrow twice -} -${'```'} - -## Common Pitfalls - -### Dangling References - -${'```'}rust -fn dangle() -> &String { // ❌ ERROR: missing lifetime specifier - let s = String::from("hello"); - &s // s will be dropped, leaving a dangling reference -} -${'```'} - -### ✅ Solution - -${'```'}rust -fn no_dangle() -> String { - let s = String::from("hello"); - s // Ownership is moved out -} -${'```'} - -## Benefits - -- ✅ **No null pointer dereferences** -- ✅ **No data races** -- ✅ **No use-after-free** -- ✅ **No memory leaks** - -## Conclusion - -Rust's ownership system eliminates entire classes of bugs at compile time. While it has a learning curve, the benefits in safety and performance are worth it. - -## Further Reading - -- [The Rust Book - Ownership](https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html) -- [Rust by Example - Ownership](https://doc.rust-lang.org/rust-by-example/scope/move.html) -- [Rustlings Exercises](https://github.com/rust-lang/rustlings) - ---- - -*Questions? Reach out on [Twitter](https://twitter.com/rustlang) or join the [Rust Discord](https://discord.gg/rust-lang)* -`; diff --git a/tools/server/webui/tests/stories/fixtures/data-analysis.ts b/tools/server/webui/tests/stories/fixtures/data-analysis.ts deleted file mode 100644 index 6fec32dad..000000000 --- a/tools/server/webui/tests/stories/fixtures/data-analysis.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Data Analysis Report -export const DATA_ANALYSIS_MD = String.raw` -# Q4 2024 Business Analytics Report - -*Executive Summary • Generated on January 15, 2025* - -## 📊 Key Performance Indicators - -${'```'} -Daily Active Users (DAU): 1.2M (+65% YoY) -Monthly Active Users (MAU): 4.5M (+48% YoY) -User Retention (Day 30): 68% (+12pp YoY) -Average Session Duration: 24min (+35% YoY) -${'```'} - -## 🎯 Product Performance - -### Feature Adoption Rates - -1. **AI Assistant**: 78% of users (↑ from 45%) -2. **Collaboration Tools**: 62% of users (↑ from 38%) -3. **Analytics Dashboard**: 54% of users (↑ from 31%) -4. **Mobile App**: 41% of users (↑ from 22%) - -### Customer Satisfaction - -| Metric | Q4 2024 | Q3 2024 | Change | -|--------|---------|---------|--------| -| **NPS Score** | 72 | 68 | +4 | -| **CSAT** | 4.6/5 | 4.4/5 | +0.2 | -| **Support Tickets** | 2,340 | 2,890 | -19% | -| **Resolution Time** | 4.2h | 5.1h | -18% | - -## 💰 Revenue Metrics - -### Monthly Recurring Revenue (MRR) - -- **Current MRR**: $2.8M (+42% YoY) -- **New MRR**: $340K -- **Expansion MRR**: $180K -- **Churned MRR**: $95K -- **Net New MRR**: $425K - -### Customer Acquisition - -${'```'} -Cost per Acquisition (CAC): $127 (-23% YoY) -Customer Lifetime Value: $1,840 (+31% YoY) -LTV:CAC Ratio: 14.5:1 -Payback Period: 3.2 months -${'```'} - -## 🌍 Geographic Performance - -### Revenue by Region - -1. **North America**: 45% ($1.26M) -2. **Europe**: 32% ($896K) -3. **Asia-Pacific**: 18% ($504K) -4. **Other**: 5% ($140K) - -### Growth Opportunities - -- **APAC**: 89% YoY growth potential -- **Latin America**: Emerging market entry -- **Middle East**: Enterprise expansion - -## 📱 Channel Performance - -### Traffic Sources - -| Channel | Sessions | Conversion | Revenue | -|---------|----------|------------|---------| -| **Organic Search** | 45% | 3.2% | $1.1M | -| **Direct** | 28% | 4.1% | $850K | -| **Social Media** | 15% | 2.8% | $420K | -| **Paid Ads** | 12% | 5.5% | $430K | - -### Marketing ROI - -- **Content Marketing**: 340% ROI -- **Email Campaigns**: 280% ROI -- **Social Media**: 190% ROI -- **Paid Search**: 220% ROI - -## 🔍 User Behavior Analysis - -### Session Patterns - -- **Peak Hours**: 9-11 AM, 2-4 PM EST -- **Mobile Usage**: 67% of sessions -- **Average Pages/Session**: 4.8 -- **Bounce Rate**: 23% (↓ from 31%) - -### Feature Usage Heatmap - -Most used features in order: -1. Dashboard (89% of users) -2. Search (76% of users) -3. Reports (64% of users) -4. Settings (45% of users) -5. Integrations (32% of users) - -## 💡 Recommendations - -1. **Invest** in AI capabilities (+$2M budget) -2. **Expand** sales team in APAC region -3. **Improve** onboarding to reduce churn -4. **Launch** enterprise security features - -## Appendix - -### Methodology - -Data collected from: -- Internal analytics (Amplitude) -- Customer surveys (n=2,450) -- Financial systems (NetSuite) -- Market research (Gartner) - ---- - -*Report prepared by Data Analytics Team • [View Interactive Dashboard](https://analytics.example.com)* -`; diff --git a/tools/server/webui/tests/stories/fixtures/empty.ts b/tools/server/webui/tests/stories/fixtures/empty.ts deleted file mode 100644 index 05286e7a7..000000000 --- a/tools/server/webui/tests/stories/fixtures/empty.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Empty state -export const EMPTY_MD = ''; diff --git a/tools/server/webui/tests/stories/fixtures/math-formulas.ts b/tools/server/webui/tests/stories/fixtures/math-formulas.ts deleted file mode 100644 index 1355256b2..000000000 --- a/tools/server/webui/tests/stories/fixtures/math-formulas.ts +++ /dev/null @@ -1,221 +0,0 @@ -/* eslint-disable no-irregular-whitespace */ -// Math Formulas Content -export const MATH_FORMULAS_MD = String.raw` -# Mathematical Formulas and Expressions - -This document demonstrates various mathematical notation and formulas that can be rendered using LaTeX syntax in markdown. - -## Basic Arithmetic - -### Addition and Summation -$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$ - -## Algebra - -### Quadratic Formula -The solutions to $ax^2 + bx + c = 0$ are: -$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$ - -### Binomial Theorem -$$(x + y)^n = \sum_{k=0}^{n} \binom{n}{k} x^{n-k} y^k$$ - -## Calculus - -### Derivatives -The derivative of $f(x) = x^n$ is: -$$f'(x) = nx^{n-1}$$ - -### Integration -$$\int_a^b f(x) \, dx = F(b) - F(a)$$ - -### Fundamental Theorem of Calculus -$$\frac{d}{dx} \int_a^x f(t) \, dt = f(x)$$ - -## Linear Algebra - -### Matrix Multiplication -If $A$ is an $m \times n$ matrix and $B$ is an $n \times p$ matrix, then: -$$C_{ij} = \sum_{k=1}^{n} A_{ik} B_{kj}$$ - -### Eigenvalues and Eigenvectors -For a square matrix $A$, if $Av = \lambda v$ for some non-zero vector $v$, then: -- $\lambda$ is an eigenvalue -- $v$ is an eigenvector - -## Statistics and Probability - -### Normal Distribution -The probability density function is: -$$f(x) = \frac{1}{\sigma\sqrt{2\pi}} e^{-\frac{1}{2}\left(\frac{x-\mu}{\sigma}\right)^2}$$ - -### Bayes' Theorem -$$P(A|B) = \frac{P(B|A) \cdot P(A)}{P(B)}$$ - -### Central Limit Theorem -For large $n$, the sample mean $\bar{X}$ is approximately: -$$\bar{X} \sim N\left(\mu, \frac{\sigma^2}{n}\right)$$ - -## Trigonometry - -### Pythagorean Identity -$$\sin^2\theta + \cos^2\theta = 1$$ - -### Euler's Formula -$$e^{i\theta} = \cos\theta + i\sin\theta$$ - -### Taylor Series for Sine -$$\sin x = \sum_{n=0}^{\infty} \frac{(-1)^n}{(2n+1)!} x^{2n+1} = x - \frac{x^3}{3!} + \frac{x^5}{5!} - \frac{x^7}{7!} + \cdots$$ - -## Complex Analysis - -### Complex Numbers -A complex number can be written as: -$$z = a + bi = r e^{i\theta}$$ - -where $r = |z| = \sqrt{a^2 + b^2}$ and $\theta = \arg(z)$ - -### Cauchy-Riemann Equations -For a function $f(z) = u(x,y) + iv(x,y)$ to be analytic: -$$\frac{\partial u}{\partial x} = \frac{\partial v}{\partial y}, \quad \frac{\partial u}{\partial y} = -\frac{\partial v}{\partial x}$$ - -## Differential Equations - -### First-order Linear ODE -$$\frac{dy}{dx} + P(x)y = Q(x)$$ - -Solution: $y = e^{-\int P(x)dx}\left[\int Q(x)e^{\int P(x)dx}dx + C\right]$ - -### Heat Equation -$$\frac{\partial u}{\partial t} = \alpha \frac{\partial^2 u}{\partial x^2}$$ - -## Number Theory - -### Prime Number Theorem -$$\pi(x) \sim \frac{x}{\ln x}$$ - -where $\pi(x)$ is the number of primes less than or equal to $x$. - -### Fermat's Last Theorem -For $n > 2$, there are no positive integers $a$, $b$, and $c$ such that: -$$a^n + b^n = c^n$$ - -## Set Theory - -### De Morgan's Laws -$$\overline{A \cup B} = \overline{A} \cap \overline{B}$$ -$$\overline{A \cap B} = \overline{A} \cup \overline{B}$$ - -## Advanced Topics - -### Riemann Zeta Function -$$\zeta(s) = \sum_{n=1}^{\infty} \frac{1}{n^s} = \prod_{p \text{ prime}} \frac{1}{1-p^{-s}}$$ - -### Maxwell's Equations -$$\nabla \cdot \mathbf{E} = \frac{\rho}{\epsilon_0}$$ -$$\nabla \cdot \mathbf{B} = 0$$ -$$\nabla \times \mathbf{E} = -\frac{\partial \mathbf{B}}{\partial t}$$ -$$\nabla \times \mathbf{B} = \mu_0\mathbf{J} + \mu_0\epsilon_0\frac{\partial \mathbf{E}}{\partial t}$$ - -### Schrödinger Equation -$$i\hbar\frac{\partial}{\partial t}\Psi(\mathbf{r},t) = \hat{H}\Psi(\mathbf{r},t)$$ - -## Inline Math Examples - -Here are some inline mathematical expressions: - -- The golden ratio: $\phi = \frac{1 + \sqrt{5}}{2} \approx 1.618$ -- Euler's number: $e = \lim_{n \to \infty} \left(1 + \frac{1}{n}\right)^n$ -- Pi: $\pi = 4 \sum_{n=0}^{\infty} \frac{(-1)^n}{2n+1}$ -- Square root of 2: $\sqrt{2} = 1.41421356...$ - -## Fractions and Radicals - -Complex fraction: $\frac{\frac{a}{b} + \frac{c}{d}}{\frac{e}{f} - \frac{g}{h}}$ - -Nested radicals: $\sqrt{2 + \sqrt{3 + \sqrt{4 + \sqrt{5}}}}$ - -## Summations and Products - -### Geometric Series -$$\sum_{n=0}^{\infty} ar^n = \frac{a}{1-r} \quad \text{for } |r| < 1$$ - -### Product Notation -$$n! = \prod_{k=1}^{n} k$$ - -### Double Summation -$$\sum_{i=1}^{m} \sum_{j=1}^{n} a_{ij}$$ - -## Limits - -$$\lim_{x \to 0} \frac{\sin x}{x} = 1$$ - -$$\lim_{n \to \infty} \left(1 + \frac{x}{n}\right)^n = e^x$$ - -## Further Bracket Styles and Amounts - -- \( \mathrm{GL}_2(\mathbb{F}_7) \): Group of invertible matrices with entries in \(\mathbb{F}_7\). -- Some kernel of \(\mathrm{SL}_2(\mathbb{F}_7)\): - \[ - \left\{ \begin{pmatrix} 1 & 0 \\ 0 & 1 \end{pmatrix}, \begin{pmatrix} -1 & 0 \\ 0 & -1 \end{pmatrix} \right\} = \{\pm I\} - \] -- Algebra: -\[ -x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a} -\] -- $100 and $12.99 are amounts, not LaTeX. -- I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000. -- Emma buys 2 cupcakes for $3 each and 1 cookie for $1.50. How much money does she spend in total? -- Maria has $20. She buys a notebook for $4.75 and a pack of pencils for $3.25. How much change does she receive? -- 1 kg の質量は - \[ - E = (1\ \text{kg}) \times (3.0 \times 10^8\ \text{m/s})^2 \approx 9.0 \times 10^{16}\ \text{J} - \] - というエネルギーに相当します。これは約 21 百万トンの TNT が爆発したときのエネルギーに匹敵します。 -- Algebra: \[ -x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a} -\] -- Algebraic topology, Homotopy Groups of $\mathbb{S}^3$: -$$\pi_n(\mathbb{S}^3) = \begin{cases} -\mathbb{Z} & n = 3 \\ -0 & n > 3, n \neq 4 \\ -\mathbb{Z}_2 & n = 4 \\ -\end{cases}$$ -- Spacer preceded by backslash: -\[ -\boxed{ -\begin{aligned} -N_{\text{att}}^{\text{(MHA)}} &= -h \bigl[\, d_{\text{model}}\;d_{k} + d_{\text{model}}\;d_{v}\, \bigr] && (\text{Q,K,V の重み})\\ -&\quad+ h(d_{k}+d_{k}+d_{v}) && (\text{バイアス Q,K,V)}\\[4pt] -&\quad+ (h d_{v})\, d_{\text{model}} && (\text{出力射影 }W^{O})\\ -&\quad+ d_{\text{model}} && (\text{バイアス }b^{O}) -\end{aligned}} -\] - -## Formulas in a Table - -| Area | Expression | Comment | -|------|------------|---------| -| **Algebra** | \[ -x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a} -\] | Quadratic formula | -| | \[ -(a+b)^{n} = \sum_{k=0}^{n}\binom{n}{k}\,a^{\,n-k}\,b^{\,k} -\] | Binomial theorem | -| | \(\displaystyle \prod_{k=1}^{n}k = n! \) | Factorial definition | -| **Geometry** | \( \mathbf{a}\cdot \mathbf{b} = \|\mathbf{a}\|\,\|\mathbf{b}\|\,\cos\theta \) | Dot product & angle | - -## No math (but chemical) - -Balanced chemical reaction with states: - -\[ -\ce{2H2(g) + O2(g) -> 2H2O(l)} -\] - -The standard enthalpy change for the reaction is: $\Delta H^\circ = \pu{-572 kJ mol^{-1}}$. - ---- - -*This document showcases various mathematical notation and formulas that can be rendered in markdown using LaTeX syntax.* -`; diff --git a/tools/server/webui/tests/stories/fixtures/readme.ts b/tools/server/webui/tests/stories/fixtures/readme.ts deleted file mode 100644 index e8b573d6c..000000000 --- a/tools/server/webui/tests/stories/fixtures/readme.ts +++ /dev/null @@ -1,136 +0,0 @@ -// README Content -export const README_MD = String.raw` -# 🚀 Awesome Web Framework - -[![npm version](https://img.shields.io/npm/v/awesome-framework.svg)](https://www.npmjs.com/package/awesome-framework) -[![Build Status](https://github.com/awesome/framework/workflows/CI/badge.svg)](https://github.com/awesome/framework/actions) -[![Coverage](https://codecov.io/gh/awesome/framework/branch/main/graph/badge.svg)](https://codecov.io/gh/awesome/framework) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - -> A modern, fast, and flexible web framework for building scalable applications - -## ✨ Features - -- 🎯 **Type-Safe** - Full TypeScript support out of the box -- ⚡ **Lightning Fast** - Built on Vite for instant HMR -- 📦 **Zero Config** - Works out of the box for most use cases -- 🎨 **Flexible** - Unopinionated with sensible defaults -- 🔧 **Extensible** - Plugin system for custom functionality -- 📱 **Responsive** - Mobile-first approach -- 🌍 **i18n Ready** - Built-in internationalization -- 🔒 **Secure** - Security best practices by default - -## 📦 Installation - -${'```'}bash -npm install awesome-framework -# or -yarn add awesome-framework -# or -pnpm add awesome-framework -${'```'} - -## 🚀 Quick Start - -### Create a new project - -${'```'}bash -npx create-awesome-app my-app -cd my-app -npm run dev -${'```'} - -### Basic Example - -${'```'}javascript -import { createApp } from 'awesome-framework'; - -const app = createApp({ - port: 3000, - middleware: ['cors', 'helmet', 'compression'] -}); - -app.get('/', (req, res) => { - res.json({ message: 'Hello World!' }); -}); - -app.listen(() => { - console.log('Server running on http://localhost:3000'); -}); -${'```'} - -## 📖 Documentation - -### Core Concepts - -- [Getting Started](https://docs.awesome.dev/getting-started) -- [Configuration](https://docs.awesome.dev/configuration) -- [Routing](https://docs.awesome.dev/routing) -- [Middleware](https://docs.awesome.dev/middleware) -- [Database](https://docs.awesome.dev/database) -- [Authentication](https://docs.awesome.dev/authentication) - -### Advanced Topics - -- [Performance Optimization](https://docs.awesome.dev/performance) -- [Deployment](https://docs.awesome.dev/deployment) -- [Testing](https://docs.awesome.dev/testing) -- [Security](https://docs.awesome.dev/security) - -## 🛠️ Development - -### Prerequisites - -- Node.js >= 18 -- pnpm >= 8 - -### Setup - -${'```'}bash -git clone https://github.com/awesome/framework.git -cd framework -pnpm install -pnpm dev -${'```'} - -### Testing - -${'```'}bash -pnpm test # Run unit tests -pnpm test:e2e # Run end-to-end tests -pnpm test:watch # Run tests in watch mode -${'```'} - -## 🤝 Contributing - -We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. - -### Contributors - -
          - - - -## 📊 Benchmarks - -| Framework | Requests/sec | Latency (ms) | Memory (MB) | -|-----------|-------------|--------------|-------------| -| **Awesome** | **45,230** | **2.1** | **42** | -| Express | 28,450 | 3.5 | 68 | -| Fastify | 41,200 | 2.3 | 48 | -| Koa | 32,100 | 3.1 | 52 | - -*Benchmarks performed on MacBook Pro M2, Node.js 20.x* - -## 📝 License - -MIT © [Awesome Team](https://github.com/awesome) - -## 🙏 Acknowledgments - -Special thanks to all our sponsors and contributors who make this project possible. - ---- - -**[Website](https://awesome.dev)** • **[Documentation](https://docs.awesome.dev)** • **[Discord](https://discord.gg/awesome)** • **[Twitter](https://twitter.com/awesomeframework)** -`; diff --git a/tools/server/webui/tests/stories/fixtures/storybook-mocks.ts b/tools/server/webui/tests/stories/fixtures/storybook-mocks.ts deleted file mode 100644 index c40a74655..000000000 --- a/tools/server/webui/tests/stories/fixtures/storybook-mocks.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { serverStore } from '$lib/stores/server.svelte'; -import { modelsStore } from '$lib/stores/models.svelte'; - -/** - * Mock server properties for Storybook testing - * This utility allows setting mock server configurations without polluting production code - */ -export function mockServerProps(props: Partial): void { - // Reset any pointer-events from previous tests (dropdown cleanup) - const body = document.querySelector('body'); - if (body) body.style.pointerEvents = ''; - - // Directly set the props for testing purposes - (serverStore as unknown as { props: ApiLlamaCppServerProps }).props = { - model_path: props.model_path || 'test-model', - modalities: { - vision: props.modalities?.vision ?? false, - audio: props.modalities?.audio ?? false - }, - ...props - } as ApiLlamaCppServerProps; - - // Set router mode role so activeModelId can be set - (serverStore as unknown as { props: ApiLlamaCppServerProps }).props.role = 'ROUTER'; - - // Also mock modelsStore methods for modality checking - const vision = props.modalities?.vision ?? false; - const audio = props.modalities?.audio ?? false; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (modelsStore as any).modelSupportsVision = () => vision; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (modelsStore as any).modelSupportsAudio = () => audio; - - // Mock models list with a test model so activeModelId can be resolved - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (modelsStore as any).models = [ - { - id: 'test-model', - name: 'Test Model', - model: 'test-model' - } - ]; - - // Mock selectedModelId - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (modelsStore as any).selectedModelId = 'test-model'; -} - -/** - * Reset server store to clean state for testing - */ -export function resetServerStore(): void { - (serverStore as unknown as { props: ApiLlamaCppServerProps }).props = { - model_path: '', - modalities: { - vision: false, - audio: false - } - } as ApiLlamaCppServerProps; - (serverStore as unknown as { error: string }).error = ''; - (serverStore as unknown as { loading: boolean }).loading = false; -} - -/** - * Common mock configurations for Storybook stories - */ -export const mockConfigs = { - visionOnly: { - modalities: { vision: true, audio: false } - }, - audioOnly: { - modalities: { vision: false, audio: true } - }, - bothModalities: { - modalities: { vision: true, audio: true } - }, - noModalities: { - modalities: { vision: false, audio: false } - } -} as const; diff --git a/tools/server/webui/tests/unit/agentic-sections.test.ts b/tools/server/webui/tests/unit/agentic-sections.test.ts deleted file mode 100644 index d102bd04a..000000000 --- a/tools/server/webui/tests/unit/agentic-sections.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { deriveAgenticSections, hasAgenticContent } from '$lib/utils/agentic'; -import { AgenticSectionType, MessageRole } from '$lib/enums'; -import type { DatabaseMessage } from '$lib/types/database'; -import type { ApiChatCompletionToolCall } from '$lib/types/api'; - -function makeAssistant(overrides: Partial = {}): DatabaseMessage { - return { - id: overrides.id ?? 'ast-1', - convId: 'conv-1', - type: 'text', - timestamp: Date.now(), - role: MessageRole.ASSISTANT, - content: overrides.content ?? '', - parent: null, - children: [], - ...overrides - } as DatabaseMessage; -} - -function makeToolMsg(overrides: Partial = {}): DatabaseMessage { - return { - id: overrides.id ?? 'tool-1', - convId: 'conv-1', - type: 'text', - timestamp: Date.now(), - role: MessageRole.TOOL, - content: overrides.content ?? 'tool result', - parent: null, - children: [], - toolCallId: overrides.toolCallId ?? 'call_1', - ...overrides - } as DatabaseMessage; -} - -describe('deriveAgenticSections', () => { - it('returns empty array for assistant with no content', () => { - const msg = makeAssistant({ content: '' }); - const sections = deriveAgenticSections(msg); - expect(sections).toEqual([]); - }); - - it('returns text section for simple assistant message', () => { - const msg = makeAssistant({ content: 'Hello world' }); - const sections = deriveAgenticSections(msg); - expect(sections).toHaveLength(1); - expect(sections[0].type).toBe(AgenticSectionType.TEXT); - expect(sections[0].content).toBe('Hello world'); - }); - - it('returns reasoning + text for message with reasoning', () => { - const msg = makeAssistant({ - content: 'Answer is 4.', - reasoningContent: 'Let me think...' - }); - const sections = deriveAgenticSections(msg); - expect(sections).toHaveLength(2); - expect(sections[0].type).toBe(AgenticSectionType.REASONING); - expect(sections[0].content).toBe('Let me think...'); - expect(sections[1].type).toBe(AgenticSectionType.TEXT); - }); - - it('single turn: assistant with tool calls and results', () => { - const msg = makeAssistant({ - content: 'Let me check.', - toolCalls: JSON.stringify([ - { id: 'call_1', type: 'function', function: { name: 'search', arguments: '{"q":"test"}' } } - ]) - }); - const toolResult = makeToolMsg({ - toolCallId: 'call_1', - content: 'Found 3 results' - }); - const sections = deriveAgenticSections(msg, [toolResult]); - expect(sections).toHaveLength(2); - expect(sections[0].type).toBe(AgenticSectionType.TEXT); - expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL); - expect(sections[1].toolName).toBe('search'); - expect(sections[1].toolResult).toBe('Found 3 results'); - }); - - it('single turn: pending tool call without result', () => { - const msg = makeAssistant({ - toolCalls: JSON.stringify([ - { id: 'call_1', type: 'function', function: { name: 'bash', arguments: '{}' } } - ]) - }); - const sections = deriveAgenticSections(msg, [], [], true); - expect(sections).toHaveLength(1); - expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING); - expect(sections[0].toolName).toBe('bash'); - }); - - it('multi-turn: two assistant turns grouped as one session', () => { - const assistant1 = makeAssistant({ - id: 'ast-1', - content: 'Turn 1 text', - toolCalls: JSON.stringify([ - { id: 'call_1', type: 'function', function: { name: 'search', arguments: '{"q":"foo"}' } } - ]) - }); - const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'result 1' }); - const assistant2 = makeAssistant({ - id: 'ast-2', - content: 'Final answer based on results.' - }); - - // toolMessages contains both tool result and continuation assistant - const sections = deriveAgenticSections(assistant1, [tool1, assistant2]); - expect(sections).toHaveLength(3); - // Turn 1 - expect(sections[0].type).toBe(AgenticSectionType.TEXT); - expect(sections[0].content).toBe('Turn 1 text'); - expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL); - expect(sections[1].toolName).toBe('search'); - expect(sections[1].toolResult).toBe('result 1'); - // Turn 2 (final) - expect(sections[2].type).toBe(AgenticSectionType.TEXT); - expect(sections[2].content).toBe('Final answer based on results.'); - }); - - it('multi-turn: three turns with tool calls', () => { - const assistant1 = makeAssistant({ - id: 'ast-1', - content: '', - toolCalls: JSON.stringify([ - { id: 'call_1', type: 'function', function: { name: 'list_files', arguments: '{}' } } - ]) - }); - const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'file1 file2' }); - const assistant2 = makeAssistant({ - id: 'ast-2', - content: 'Reading file1...', - toolCalls: JSON.stringify([ - { - id: 'call_2', - type: 'function', - function: { name: 'read_file', arguments: '{"path":"file1"}' } - } - ]) - }); - const tool2 = makeToolMsg({ id: 'tool-2', toolCallId: 'call_2', content: 'contents of file1' }); - const assistant3 = makeAssistant({ - id: 'ast-3', - content: 'Here is the analysis.', - reasoningContent: 'The file contains...' - }); - - const sections = deriveAgenticSections(assistant1, [tool1, assistant2, tool2, assistant3]); - // Turn 1: tool_call (no text since content is empty) - // Turn 2: text + tool_call - // Turn 3: reasoning + text - expect(sections).toHaveLength(5); - expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL); - expect(sections[0].toolName).toBe('list_files'); - expect(sections[1].type).toBe(AgenticSectionType.TEXT); - expect(sections[1].content).toBe('Reading file1...'); - expect(sections[2].type).toBe(AgenticSectionType.TOOL_CALL); - expect(sections[2].toolName).toBe('read_file'); - expect(sections[3].type).toBe(AgenticSectionType.REASONING); - expect(sections[4].type).toBe(AgenticSectionType.TEXT); - expect(sections[4].content).toBe('Here is the analysis.'); - }); - - it('returns REASONING_PENDING when streaming with only reasoning content', () => { - const msg = makeAssistant({ - reasoningContent: 'Let me think about this...' - }); - const sections = deriveAgenticSections(msg, [], [], true); - expect(sections).toHaveLength(1); - expect(sections[0].type).toBe(AgenticSectionType.REASONING_PENDING); - expect(sections[0].content).toBe('Let me think about this...'); - }); - - it('returns REASONING (not pending) when streaming but text content has appeared', () => { - const msg = makeAssistant({ - content: 'The answer is', - reasoningContent: 'Let me think...' - }); - const sections = deriveAgenticSections(msg, [], [], true); - expect(sections).toHaveLength(2); - expect(sections[0].type).toBe(AgenticSectionType.REASONING); - expect(sections[1].type).toBe(AgenticSectionType.TEXT); - }); - - it('returns REASONING (not pending) when not streaming', () => { - const msg = makeAssistant({ - reasoningContent: 'Let me think...' - }); - const sections = deriveAgenticSections(msg, [], [], false); - expect(sections).toHaveLength(1); - expect(sections[0].type).toBe(AgenticSectionType.REASONING); - }); - - it('multi-turn: streaming tool calls on last turn', () => { - const assistant1 = makeAssistant({ - toolCalls: JSON.stringify([ - { id: 'call_1', type: 'function', function: { name: 'search', arguments: '{}' } } - ]) - }); - const tool1 = makeToolMsg({ toolCallId: 'call_1', content: 'result' }); - const assistant2 = makeAssistant({ id: 'ast-2', content: '' }); - - const streamingToolCalls: ApiChatCompletionToolCall[] = [ - { id: 'call_2', type: 'function', function: { name: 'write_file', arguments: '{"pa' } } - ]; - - const sections = deriveAgenticSections(assistant1, [tool1, assistant2], streamingToolCalls); - // Turn 1: tool_call - // Turn 2 (streaming): streaming tool call - expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL)).toBe(true); - expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL_STREAMING)).toBe(true); - }); -}); - -describe('hasAgenticContent', () => { - it('returns false for plain assistant', () => { - const msg = makeAssistant({ content: 'Just text' }); - expect(hasAgenticContent(msg)).toBe(false); - }); - - it('returns true when message has toolCalls', () => { - const msg = makeAssistant({ - toolCalls: JSON.stringify([ - { id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } } - ]) - }); - expect(hasAgenticContent(msg)).toBe(true); - }); - - it('returns true when toolMessages are provided', () => { - const msg = makeAssistant(); - const tool = makeToolMsg(); - expect(hasAgenticContent(msg, [tool])).toBe(true); - }); - - it('returns false for empty toolCalls JSON', () => { - const msg = makeAssistant({ toolCalls: '[]' }); - expect(hasAgenticContent(msg)).toBe(false); - }); -}); diff --git a/tools/server/webui/tests/unit/agentic-strip.test.ts b/tools/server/webui/tests/unit/agentic-strip.test.ts deleted file mode 100644 index 86867f8a9..000000000 --- a/tools/server/webui/tests/unit/agentic-strip.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { LEGACY_AGENTIC_REGEX } from '$lib/constants/agentic'; - -/** - * Tests for legacy marker stripping (used in migration). - * The new system does not embed markers in content - these tests verify - * the legacy regex patterns still work for the migration code. - */ - -// Mirror the legacy stripping logic used during migration -function stripLegacyContextMarkers(content: string): string { - return content - .replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '') - .replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '') - .replace(new RegExp(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK.source, 'g'), '') - .replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, ''); -} - -// A realistic complete tool call block as stored in old message.content -const COMPLETE_BLOCK = - '\n\n<<>>\n' + - '<<>>\n' + - '<<>>\n' + - '{"command":"ls /tmp","description":"list tmp"}\n' + - '<<>>\n' + - 'file1.txt\nfile2.txt\n' + - '<<>>\n'; - -// Partial block: streaming was cut before END arrived. -const OPEN_BLOCK = - '\n\n<<>>\n' + - '<<>>\n' + - '<<>>\n' + - '{"command":"ls /tmp","description":"list tmp"}\n' + - '<<>>\n' + - 'partial output...'; - -describe('legacy agentic marker stripping (for migration)', () => { - it('strips a complete tool call block, leaving surrounding text', () => { - const input = 'Before.' + COMPLETE_BLOCK + 'After.'; - const result = stripLegacyContextMarkers(input); - expect(result).not.toContain('<<<'); - expect(result).toContain('Before.'); - expect(result).toContain('After.'); - }); - - it('strips multiple complete tool call blocks', () => { - const input = 'A' + COMPLETE_BLOCK + 'B' + COMPLETE_BLOCK + 'C'; - const result = stripLegacyContextMarkers(input); - expect(result).not.toContain('<<<'); - expect(result).toContain('A'); - expect(result).toContain('B'); - expect(result).toContain('C'); - }); - - it('strips an open/partial tool call block (no END marker)', () => { - const input = 'Lead text.' + OPEN_BLOCK; - const result = stripLegacyContextMarkers(input); - expect(result).toBe('Lead text.'); - expect(result).not.toContain('<<<'); - }); - - it('does not alter content with no markers', () => { - const input = 'Just a normal assistant response.'; - expect(stripLegacyContextMarkers(input)).toBe(input); - }); - - it('strips reasoning block independently', () => { - const input = '<<>>think hard<<>>Answer.'; - expect(stripLegacyContextMarkers(input)).toBe('Answer.'); - }); - - it('strips both reasoning and agentic blocks together', () => { - const input = - '<<>>plan<<>>' + - 'Some text.' + - COMPLETE_BLOCK; - expect(stripLegacyContextMarkers(input)).not.toContain('<<<'); - expect(stripLegacyContextMarkers(input)).toContain('Some text.'); - }); - - it('empty string survives', () => { - expect(stripLegacyContextMarkers('')).toBe(''); - }); - - it('detects legacy markers', () => { - expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('normal text')).toBe(false); - expect( - LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('text<<>>more') - ).toBe(true); - expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('<<>>think')).toBe( - true - ); - }); -}); diff --git a/tools/server/webui/tests/unit/clipboard.test.ts b/tools/server/webui/tests/unit/clipboard.test.ts deleted file mode 100644 index d8ea4899e..000000000 --- a/tools/server/webui/tests/unit/clipboard.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { AttachmentType } from '$lib/enums'; -import { - formatMessageForClipboard, - parseClipboardContent, - hasClipboardAttachments -} from '$lib/utils/clipboard'; - -describe('formatMessageForClipboard', () => { - it('returns plain content when no extras', () => { - const result = formatMessageForClipboard('Hello world', undefined); - expect(result).toBe('Hello world'); - }); - - it('returns plain content when extras is empty array', () => { - const result = formatMessageForClipboard('Hello world', []); - expect(result).toBe('Hello world'); - }); - - it('handles empty string content', () => { - const result = formatMessageForClipboard('', undefined); - expect(result).toBe(''); - }); - - it('returns plain content when extras has only non-text attachments', () => { - const extras = [ - { - type: AttachmentType.IMAGE as const, - name: 'image.png', - base64Url: 'data:image/png;base64,...' - } - ]; - const result = formatMessageForClipboard('Hello world', extras); - expect(result).toBe('Hello world'); - }); - - it('filters non-text attachments and keeps only text ones', () => { - const extras = [ - { - type: AttachmentType.IMAGE as const, - name: 'image.png', - base64Url: 'data:image/png;base64,...' - }, - { - type: AttachmentType.TEXT as const, - name: 'file.txt', - content: 'Text content' - }, - { - type: AttachmentType.PDF as const, - name: 'doc.pdf', - base64Data: 'data:application/pdf;base64,...', - content: 'PDF content', - processedAsImages: false - } - ]; - const result = formatMessageForClipboard('Hello', extras); - - expect(result).toContain('"file.txt"'); - expect(result).not.toContain('image.png'); - expect(result).not.toContain('doc.pdf'); - }); - - it('formats message with text attachments', () => { - const extras = [ - { - type: AttachmentType.TEXT as const, - name: 'file1.txt', - content: 'File 1 content' - }, - { - type: AttachmentType.TEXT as const, - name: 'file2.txt', - content: 'File 2 content' - } - ]; - const result = formatMessageForClipboard('Hello world', extras); - - expect(result).toContain('"Hello world"'); - expect(result).toContain('"type": "TEXT"'); - expect(result).toContain('"name": "file1.txt"'); - expect(result).toContain('"content": "File 1 content"'); - expect(result).toContain('"name": "file2.txt"'); - }); - - it('handles content with quotes and special characters', () => { - const content = 'Hello "world" with\nnewline'; - const extras = [ - { - type: AttachmentType.TEXT as const, - name: 'test.txt', - content: 'Test content' - } - ]; - const result = formatMessageForClipboard(content, extras); - - // Should be valid JSON - expect(result.startsWith('"')).toBe(true); - // The content should be properly escaped - const parsed = JSON.parse(result.split('\n')[0]); - expect(parsed).toBe(content); - }); - - it('converts legacy context type to TEXT type', () => { - const extras = [ - { - type: AttachmentType.LEGACY_CONTEXT as const, - name: 'legacy.txt', - content: 'Legacy content' - } - ]; - const result = formatMessageForClipboard('Hello', extras); - - expect(result).toContain('"type": "TEXT"'); - expect(result).not.toContain('"context"'); - }); - - it('handles attachment content with special characters', () => { - const extras = [ - { - type: AttachmentType.TEXT as const, - name: 'code.js', - content: 'const x = "hello\\nworld";\nconst y = `template ${var}`;' - } - ]; - const formatted = formatMessageForClipboard('Check this code', extras); - const parsed = parseClipboardContent(formatted); - - expect(parsed.textAttachments[0].content).toBe( - 'const x = "hello\\nworld";\nconst y = `template ${var}`;' - ); - }); - - it('handles unicode characters in content and attachments', () => { - const extras = [ - { - type: AttachmentType.TEXT as const, - name: 'unicode.txt', - content: '日本語テスト 🎉 émojis' - } - ]; - const formatted = formatMessageForClipboard('Привет мир 👋', extras); - const parsed = parseClipboardContent(formatted); - - expect(parsed.message).toBe('Привет мир 👋'); - expect(parsed.textAttachments[0].content).toBe('日本語テスト 🎉 émojis'); - }); - - it('formats as plain text when asPlainText is true', () => { - const extras = [ - { - type: AttachmentType.TEXT as const, - name: 'file1.txt', - content: 'File 1 content' - }, - { - type: AttachmentType.TEXT as const, - name: 'file2.txt', - content: 'File 2 content' - } - ]; - const result = formatMessageForClipboard('Hello world', extras, true); - - expect(result).toBe('Hello world\n\nFile 1 content\n\nFile 2 content'); - }); - - it('returns plain content when asPlainText is true but no attachments', () => { - const result = formatMessageForClipboard('Hello world', [], true); - expect(result).toBe('Hello world'); - }); - - it('plain text mode does not use JSON format', () => { - const extras = [ - { - type: AttachmentType.TEXT as const, - name: 'test.txt', - content: 'Test content' - } - ]; - const result = formatMessageForClipboard('Hello', extras, true); - - expect(result).not.toContain('"type"'); - expect(result).not.toContain('['); - expect(result).toBe('Hello\n\nTest content'); - }); -}); - -describe('parseClipboardContent', () => { - it('returns plain text as message when not in special format', () => { - const result = parseClipboardContent('Hello world'); - - expect(result.message).toBe('Hello world'); - expect(result.textAttachments).toHaveLength(0); - }); - - it('handles empty string input', () => { - const result = parseClipboardContent(''); - - expect(result.message).toBe(''); - expect(result.textAttachments).toHaveLength(0); - }); - - it('handles whitespace-only input', () => { - const result = parseClipboardContent(' \n\t '); - - expect(result.message).toBe(' \n\t '); - expect(result.textAttachments).toHaveLength(0); - }); - - it('returns plain text as message when starts with quote but invalid format', () => { - const result = parseClipboardContent('"Unclosed quote'); - - expect(result.message).toBe('"Unclosed quote'); - expect(result.textAttachments).toHaveLength(0); - }); - - it('returns original text when JSON array is malformed', () => { - const input = '"Hello"\n[invalid json'; - - const result = parseClipboardContent(input); - - expect(result.message).toBe('"Hello"\n[invalid json'); - expect(result.textAttachments).toHaveLength(0); - }); - - it('parses message with text attachments', () => { - const input = `"Hello world" -[ - {"type":"TEXT","name":"file1.txt","content":"File 1 content"}, - {"type":"TEXT","name":"file2.txt","content":"File 2 content"} -]`; - - const result = parseClipboardContent(input); - - expect(result.message).toBe('Hello world'); - expect(result.textAttachments).toHaveLength(2); - expect(result.textAttachments[0].name).toBe('file1.txt'); - expect(result.textAttachments[0].content).toBe('File 1 content'); - expect(result.textAttachments[1].name).toBe('file2.txt'); - expect(result.textAttachments[1].content).toBe('File 2 content'); - }); - - it('handles escaped quotes in message', () => { - const input = `"Hello \\"world\\" with quotes" -[ - {"type":"TEXT","name":"file.txt","content":"test"} -]`; - - const result = parseClipboardContent(input); - - expect(result.message).toBe('Hello "world" with quotes'); - expect(result.textAttachments).toHaveLength(1); - }); - - it('handles newlines in message', () => { - const input = `"Hello\\nworld" -[ - {"type":"TEXT","name":"file.txt","content":"test"} -]`; - - const result = parseClipboardContent(input); - - expect(result.message).toBe('Hello\nworld'); - expect(result.textAttachments).toHaveLength(1); - }); - - it('returns message only when no array follows', () => { - const input = '"Just a quoted string"'; - - const result = parseClipboardContent(input); - - expect(result.message).toBe('Just a quoted string'); - expect(result.textAttachments).toHaveLength(0); - }); - - it('filters out invalid attachment objects', () => { - const input = `"Hello" -[ - {"type":"TEXT","name":"valid.txt","content":"valid"}, - {"type":"INVALID","name":"invalid.txt","content":"invalid"}, - {"name":"missing-type.txt","content":"missing"}, - {"type":"TEXT","content":"missing name"} -]`; - - const result = parseClipboardContent(input); - - expect(result.message).toBe('Hello'); - expect(result.textAttachments).toHaveLength(1); - expect(result.textAttachments[0].name).toBe('valid.txt'); - }); - - it('handles empty attachments array', () => { - const input = '"Hello"\n[]'; - - const result = parseClipboardContent(input); - - expect(result.message).toBe('Hello'); - expect(result.textAttachments).toHaveLength(0); - }); - - it('roundtrips correctly with formatMessageForClipboard', () => { - const originalContent = 'Hello "world" with\nspecial characters'; - const originalExtras = [ - { - type: AttachmentType.TEXT as const, - name: 'file1.txt', - content: 'Content with\nnewlines and "quotes"' - }, - { - type: AttachmentType.TEXT as const, - name: 'file2.txt', - content: 'Another file' - } - ]; - - const formatted = formatMessageForClipboard(originalContent, originalExtras); - const parsed = parseClipboardContent(formatted); - - expect(parsed.message).toBe(originalContent); - expect(parsed.textAttachments).toHaveLength(2); - expect(parsed.textAttachments[0].name).toBe('file1.txt'); - expect(parsed.textAttachments[0].content).toBe('Content with\nnewlines and "quotes"'); - expect(parsed.textAttachments[1].name).toBe('file2.txt'); - expect(parsed.textAttachments[1].content).toBe('Another file'); - }); -}); - -describe('hasClipboardAttachments', () => { - it('returns false for plain text', () => { - expect(hasClipboardAttachments('Hello world')).toBe(false); - }); - - it('returns false for empty string', () => { - expect(hasClipboardAttachments('')).toBe(false); - }); - - it('returns false for quoted string without attachments', () => { - expect(hasClipboardAttachments('"Hello world"')).toBe(false); - }); - - it('returns true for valid format with attachments', () => { - const input = `"Hello" -[{"type":"TEXT","name":"file.txt","content":"test"}]`; - - expect(hasClipboardAttachments(input)).toBe(true); - }); - - it('returns false for format with empty attachments array', () => { - const input = '"Hello"\n[]'; - - expect(hasClipboardAttachments(input)).toBe(false); - }); - - it('returns false for malformed JSON', () => { - expect(hasClipboardAttachments('"Hello"\n[broken')).toBe(false); - }); -}); - -describe('roundtrip edge cases', () => { - it('preserves empty message with attachments', () => { - const extras = [ - { - type: AttachmentType.TEXT as const, - name: 'file.txt', - content: 'Content only' - } - ]; - const formatted = formatMessageForClipboard('', extras); - const parsed = parseClipboardContent(formatted); - - expect(parsed.message).toBe(''); - expect(parsed.textAttachments).toHaveLength(1); - expect(parsed.textAttachments[0].content).toBe('Content only'); - }); - - it('preserves attachment with empty content', () => { - const extras = [ - { - type: AttachmentType.TEXT as const, - name: 'empty.txt', - content: '' - } - ]; - const formatted = formatMessageForClipboard('Message', extras); - const parsed = parseClipboardContent(formatted); - - expect(parsed.message).toBe('Message'); - expect(parsed.textAttachments).toHaveLength(1); - expect(parsed.textAttachments[0].content).toBe(''); - }); - - it('preserves multiple backslashes', () => { - const content = 'Path: C:\\\\Users\\\\test\\\\file.txt'; - const extras = [ - { - type: AttachmentType.TEXT as const, - name: 'path.txt', - content: 'D:\\\\Data\\\\file' - } - ]; - const formatted = formatMessageForClipboard(content, extras); - const parsed = parseClipboardContent(formatted); - - expect(parsed.message).toBe(content); - expect(parsed.textAttachments[0].content).toBe('D:\\\\Data\\\\file'); - }); - - it('preserves tabs and various whitespace', () => { - const content = 'Line1\t\tTabbed\n Spaced\r\nCRLF'; - const extras = [ - { - type: AttachmentType.TEXT as const, - name: 'whitespace.txt', - content: '\t\t\n\n ' - } - ]; - const formatted = formatMessageForClipboard(content, extras); - const parsed = parseClipboardContent(formatted); - - expect(parsed.message).toBe(content); - expect(parsed.textAttachments[0].content).toBe('\t\t\n\n '); - }); -}); diff --git a/tools/server/webui/tests/unit/latex-protection.test.ts b/tools/server/webui/tests/unit/latex-protection.test.ts deleted file mode 100644 index 84328dbc1..000000000 --- a/tools/server/webui/tests/unit/latex-protection.test.ts +++ /dev/null @@ -1,376 +0,0 @@ -/* eslint-disable no-irregular-whitespace */ -import { describe, it, expect, test } from 'vitest'; -import { maskInlineLaTeX, preprocessLaTeX } from '$lib/utils/latex-protection'; - -describe('maskInlineLaTeX', () => { - it('should protect LaTeX $x + y$ but not money $3.99', () => { - const latexExpressions: string[] = []; - const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.'; - const output = maskInlineLaTeX(input, latexExpressions); - - expect(output).toBe('I have $10, $3.99 and <> and <>. The amount is $2,000.'); - expect(latexExpressions).toEqual(['$x + y$', '$100x$']); - }); - - it('should ignore money like $5 and $12.99', () => { - const latexExpressions: string[] = []; - const input = 'Prices are $12.99 and $5. Tax?'; - const output = maskInlineLaTeX(input, latexExpressions); - - expect(output).toBe('Prices are $12.99 and $5. Tax?'); - expect(latexExpressions).toEqual([]); - }); - - it('should protect inline math $a^2 + b^2$ even after text', () => { - const latexExpressions: string[] = []; - const input = 'Pythagorean: $a^2 + b^2 = c^2$.'; - const output = maskInlineLaTeX(input, latexExpressions); - - expect(output).toBe('Pythagorean: <>.'); - expect(latexExpressions).toEqual(['$a^2 + b^2 = c^2$']); - }); - - it('should not protect math that has letter after closing $ (e.g. units)', () => { - const latexExpressions: string[] = []; - const input = 'The cost is $99 and change.'; - const output = maskInlineLaTeX(input, latexExpressions); - - expect(output).toBe('The cost is $99 and change.'); - expect(latexExpressions).toEqual([]); - }); - - it('should allow $x$ followed by punctuation', () => { - const latexExpressions: string[] = []; - const input = 'We know $x$, right?'; - const output = maskInlineLaTeX(input, latexExpressions); - - expect(output).toBe('We know <>, right?'); - expect(latexExpressions).toEqual(['$x$']); - }); - - it('should work across multiple lines', () => { - const latexExpressions: string[] = []; - const input = `Emma buys cupcakes for $3 each.\nHow much is $x + y$?`; - const output = maskInlineLaTeX(input, latexExpressions); - - expect(output).toBe(`Emma buys cupcakes for $3 each.\nHow much is <>?`); - expect(latexExpressions).toEqual(['$x + y$']); - }); - - it('should not protect $100 but protect $matrix$', () => { - const latexExpressions: string[] = []; - const input = '$100 and $\\mathrm{GL}_2(\\mathbb{F}_7)$ are different.'; - const output = maskInlineLaTeX(input, latexExpressions); - - expect(output).toBe('$100 and <> are different.'); - expect(latexExpressions).toEqual(['$\\mathrm{GL}_2(\\mathbb{F}_7)$']); - }); - - it('should skip if $ is followed by digit and alphanumeric after close (money)', () => { - const latexExpressions: string[] = []; - const input = 'I paid $5 quickly.'; - const output = maskInlineLaTeX(input, latexExpressions); - - expect(output).toBe('I paid $5 quickly.'); - expect(latexExpressions).toEqual([]); - }); - - it('should protect LaTeX even with special chars inside', () => { - const latexExpressions: string[] = []; - const input = 'Consider $\\alpha_1 + \\beta_2$ now.'; - const output = maskInlineLaTeX(input, latexExpressions); - - expect(output).toBe('Consider <> now.'); - expect(latexExpressions).toEqual(['$\\alpha_1 + \\beta_2$']); - }); - - it('short text', () => { - const latexExpressions: string[] = ['$0$']; - const input = '$a$\n$a$ and $b$'; - const output = maskInlineLaTeX(input, latexExpressions); - - expect(output).toBe('<>\n<> and <>'); - expect(latexExpressions).toEqual(['$0$', '$a$', '$a$', '$b$']); - }); - - it('empty text', () => { - const latexExpressions: string[] = []; - const input = '$\n$$\n'; - const output = maskInlineLaTeX(input, latexExpressions); - - expect(output).toBe('$\n$$\n'); - expect(latexExpressions).toEqual([]); - }); - - it('LaTeX-spacer preceded by backslash', () => { - const latexExpressions: string[] = []; - const input = `\\[ -\\boxed{ -\\begin{aligned} -N_{\\text{att}}^{\\text{(MHA)}} &= -h \\bigl[\\, d_{\\text{model}}\\;d_{k} + d_{\\text{model}}\\;d_{v}\\, \\bigr] && (\\text{Q,K,V の重み})\\\\ -&\\quad+ h(d_{k}+d_{k}+d_{v}) && (\\text{バイアス Q,K,V)}\\\\[4pt] -&\\quad+ (h d_{v})\\, d_{\\text{model}} && (\\text{出力射影 }W^{O})\\\\ -&\\quad+ d_{\\text{model}} && (\\text{バイアス }b^{O}) -\\end{aligned}} -\\]`; - const output = maskInlineLaTeX(input, latexExpressions); - - expect(output).toBe(input); - expect(latexExpressions).toEqual([]); - }); -}); - -describe('preprocessLaTeX', () => { - test('converts inline \\( ... \\) to $...$', () => { - const input = - '\\( \\mathrm{GL}_2(\\mathbb{F}_7) \\): Group of invertible matrices with entries in \\(\\mathbb{F}_7\\).'; - const output = preprocessLaTeX(input); - expect(output).toBe( - '$ \\mathrm{GL}_2(\\mathbb{F}_7) $: Group of invertible matrices with entries in $\\mathbb{F}_7$.' - ); - }); - - test("don't inline \\\\( ... \\) to $...$", () => { - const input = - 'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula \\((x_1,\\ldots,x_n)\\).'; - const output = preprocessLaTeX(input); - expect(output).toBe( - 'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula $(x_1,\\ldots,x_n)$.' - ); - }); - - test('preserves display math \\[ ... \\] and protects adjacent text', () => { - const input = `Some kernel of \\(\\mathrm{SL}_2(\\mathbb{F}_7)\\): - \\[ - \\left\\{ \\begin{pmatrix} 1 & 0 \\\\ 0 & 1 \\end{pmatrix}, \\begin{pmatrix} -1 & 0 \\\\ 0 & -1 \\end{pmatrix} \\right\\} = \\{\\pm I\\} - \\]`; - const output = preprocessLaTeX(input); - - expect(output).toBe(`Some kernel of $\\mathrm{SL}_2(\\mathbb{F}_7)$: - $$ - \\left\\{ \\begin{pmatrix} 1 & 0 \\\\ 0 & 1 \\end{pmatrix}, \\begin{pmatrix} -1 & 0 \\\\ 0 & -1 \\end{pmatrix} \\right\\} = \\{\\pm I\\} - $$`); - }); - - test('handles standalone display math equation', () => { - const input = `Algebra: -\\[ -x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a} -\\]`; - const output = preprocessLaTeX(input); - - expect(output).toBe(`Algebra: -$$ -x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a} -$$`); - }); - - test('does not interpret currency values as LaTeX', () => { - const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.'; - const output = preprocessLaTeX(input); - - expect(output).toBe('I have \\$10, \\$3.99 and $x + y$ and $100x$. The amount is \\$2,000.'); - }); - - test('ignores dollar signs followed by digits (money), but keeps valid math $x + y$', () => { - const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.'; - const output = preprocessLaTeX(input); - - expect(output).toBe('I have \\$10, \\$3.99 and $x + y$ and $100x$. The amount is \\$2,000.'); - }); - - test('handles real-world word problems with amounts and no math delimiters', () => { - const input = - 'Emma buys 2 cupcakes for $3 each and 1 cookie for $1.50. How much money does she spend in total?'; - const output = preprocessLaTeX(input); - - expect(output).toBe( - 'Emma buys 2 cupcakes for \\$3 each and 1 cookie for \\$1.50. How much money does she spend in total?' - ); - }); - - test('handles decimal amounts in word problem correctly', () => { - const input = - 'Maria has $20. She buys a notebook for $4.75 and a pack of pencils for $3.25. How much change does she receive?'; - const output = preprocessLaTeX(input); - - expect(output).toBe( - 'Maria has \\$20. She buys a notebook for \\$4.75 and a pack of pencils for \\$3.25. How much change does she receive?' - ); - }); - - test('preserves display math with surrounding non-ASCII text', () => { - const input = `1 kg の質量は - \\[ - E = (1\\ \\text{kg}) \\times (3.0 \\times 10^8\\ \\text{m/s})^2 \\approx 9.0 \\times 10^{16}\\ \\text{J} - \\] - というエネルギーに相当します。これは約 21 百万トンの TNT が爆発したときのエネルギーに匹敵します。`; - const output = preprocessLaTeX(input); - - expect(output).toBe( - `1 kg の質量は - $$ - E = (1\\ \\text{kg}) \\times (3.0 \\times 10^8\\ \\text{m/s})^2 \\approx 9.0 \\times 10^{16}\\ \\text{J} - $$ - というエネルギーに相当します。これは約 21 百万トンの TNT が爆発したときのエネルギーに匹敵します。` - ); - }); - - test('LaTeX-spacer preceded by backslash', () => { - const input = `\\[ -\\boxed{ -\\begin{aligned} -N_{\\text{att}}^{\\text{(MHA)}} &= -h \\bigl[\\, d_{\\text{model}}\\;d_{k} + d_{\\text{model}}\\;d_{v}\\, \\bigr] && (\\text{Q,K,V の重み})\\\\ -&\\quad+ h(d_{k}+d_{k}+d_{v}) && (\\text{バイアス Q,K,V)}\\\\[4pt] -&\\quad+ (h d_{v})\\, d_{\\text{model}} && (\\text{出力射影 }W^{O})\\\\ -&\\quad+ d_{\\text{model}} && (\\text{バイアス }b^{O}) -\\end{aligned}} -\\]`; - const output = preprocessLaTeX(input); - expect(output).toBe( - `$$ -\\boxed{ -\\begin{aligned} -N_{\\text{att}}^{\\text{(MHA)}} &= -h \\bigl[\\, d_{\\text{model}}\\;d_{k} + d_{\\text{model}}\\;d_{v}\\, \\bigr] && (\\text{Q,K,V の重み})\\\\ -&\\quad+ h(d_{k}+d_{k}+d_{v}) && (\\text{バイアス Q,K,V)}\\\\[4pt] -&\\quad+ (h d_{v})\\, d_{\\text{model}} && (\\text{出力射影 }W^{O})\\\\ -&\\quad+ d_{\\text{model}} && (\\text{バイアス }b^{O}) -\\end{aligned}} -$$` - ); - }); - - test('converts \\[ ... \\] even when preceded by text without space', () => { - const input = 'Some line ...\nAlgebra: \\[x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}\\]'; - const output = preprocessLaTeX(input); - - expect(output).toBe( - 'Some line ...\nAlgebra: \n$$x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}$$\n' - ); - }); - - test('converts \\[ ... \\] in table-cells', () => { - const input = `| ID | Expression |\n| #1 | \\[ - x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a} -\\] |`; - const output = preprocessLaTeX(input); - - expect(output).toBe( - '| ID | Expression |\n| #1 | $x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}$ |' - ); - }); - - test('escapes isolated $ before digits ($5 → \\$5), but not valid math', () => { - const input = 'This costs $5 and this is math $x^2$. $100 is money.'; - const output = preprocessLaTeX(input); - - expect(output).toBe('This costs \\$5 and this is math $x^2$. \\$100 is money.'); - // Note: Since $x^2$ is detected as valid LaTeX, it's preserved. - // $5 becomes \$5 only *after* real math is masked — but here it's correct because the masking logic avoids treating $5 as math. - }); - - test('display with LaTeX-line-breaks', () => { - const input = String.raw`- Algebraic topology, Homotopy Groups of $\mathbb{S}^3$: -$$\pi_n(\mathbb{S}^3) = \begin{cases} -\mathbb{Z} & n = 3 \\ -0 & n > 3, n \neq 4 \\ -\mathbb{Z}_2 & n = 4 \\ -\end{cases}$$`; - const output = preprocessLaTeX(input); - // If the formula contains '\\' the $$-delimiters should be in their own line. - expect(output).toBe(`- Algebraic topology, Homotopy Groups of $\\mathbb{S}^3$: -$$\n\\pi_n(\\mathbb{S}^3) = \\begin{cases} -\\mathbb{Z} & n = 3 \\\\ -0 & n > 3, n \\neq 4 \\\\ -\\mathbb{Z}_2 & n = 4 \\\\ -\\end{cases}\n$$`); - }); - - test('handles mhchem notation safely if present', () => { - const input = 'Chemical reaction: \\( \\ce{H2O} \\) and $\\ce{CO2}$'; - const output = preprocessLaTeX(input); - - expect(output).toBe('Chemical reaction: $ \\ce{H2O} $ and $\\ce{CO2}$'); - }); - - test('preserves code blocks', () => { - const input = 'Inline code: `sum $total` and block:\n```\ndollar $amount\n```\nEnd.'; - const output = preprocessLaTeX(input); - - expect(output).toBe(input); // Code blocks prevent misinterpretation - }); - - test('preserves backslash parentheses in code blocks (GitHub issue)', () => { - const input = '```python\nfoo = "\\(bar\\)"\n```'; - const output = preprocessLaTeX(input); - - expect(output).toBe(input); // Code blocks should not have LaTeX conversion applied - }); - - test('preserves backslash brackets in code blocks', () => { - const input = '```python\nfoo = "\\[bar\\]"\n```'; - const output = preprocessLaTeX(input); - - expect(output).toBe(input); // Code blocks should not have LaTeX conversion applied - }); - - test('preserves backslash parentheses in inline code', () => { - const input = 'Use `foo = "\\(bar\\)"` in your code.'; - const output = preprocessLaTeX(input); - - expect(output).toBe(input); - }); - - test('escape backslash in mchem ce', () => { - const input = 'mchem ce:\n$\\ce{2H2(g) + O2(g) -> 2H2O(l)}$'; - const output = preprocessLaTeX(input); - - // mhchem-escape would insert a backslash here. - expect(output).toBe('mchem ce:\n$\\ce{2H2(g) + O2(g) -> 2H2O(l)}$'); - }); - - test('escape backslash in mchem pu', () => { - const input = 'mchem pu:\n$\\pu{-572 kJ mol^{-1}}$'; - const output = preprocessLaTeX(input); - - // mhchem-escape would insert a backslash here. - expect(output).toBe('mchem pu:\n$\\pu{-572 kJ mol^{-1}}$'); - }); - - test('LaTeX in blockquotes with display math', () => { - const input = - '> **Definition (limit):** \n> \\[\n> \\lim_{x\\to a} f(x) = L\n> \\]\n> means that as \\(x\\) gets close to \\(a\\).'; - const output = preprocessLaTeX(input); - - // Blockquote markers should be preserved, LaTeX should be converted - expect(output).toContain('> **Definition (limit):**'); - expect(output).toContain('$$'); - expect(output).toContain('$x$'); - expect(output).not.toContain('\\['); - expect(output).not.toContain('\\]'); - expect(output).not.toContain('\\('); - expect(output).not.toContain('\\)'); - }); - - test('LaTeX in blockquotes with inline math', () => { - const input = - "> The derivative \\(f'(x)\\) at point \\(x=a\\) measures slope.\n> Formula: \\(f'(a)=\\lim_{h\\to 0}\\frac{f(a+h)-f(a)}{h}\\)"; - const output = preprocessLaTeX(input); - - // Blockquote markers should be preserved, inline LaTeX converted to $...$ - expect(output).toContain("> The derivative $f'(x)$ at point $x=a$ measures slope."); - expect(output).toContain("> Formula: $f'(a)=\\lim_{h\\to 0}\\frac{f(a+h)-f(a)}{h}$"); - }); - - test('Mixed content with blockquotes and regular text', () => { - const input = - 'Regular text with \\(x^2\\).\n\n> Quote with \\(y^2\\).\n\nMore text with \\(z^2\\).'; - const output = preprocessLaTeX(input); - - // All LaTeX should be converted, blockquote markers preserved - expect(output).toBe('Regular text with $x^2$.\n\n> Quote with $y^2$.\n\nMore text with $z^2$.'); - }); -}); diff --git a/tools/server/webui/tests/unit/mcp-service.test.ts b/tools/server/webui/tests/unit/mcp-service.test.ts deleted file mode 100644 index afd3bdd5c..000000000 --- a/tools/server/webui/tests/unit/mcp-service.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { Client } from '@modelcontextprotocol/sdk/client'; -import { MCPService } from '$lib/services/mcp.service'; -import { MCPConnectionPhase, MCPTransportType } from '$lib/enums'; -import type { MCPConnectionLog, MCPServerConfig } from '$lib/types'; - -type DiagnosticFetchFactory = ( - serverName: string, - config: MCPServerConfig, - baseInit: RequestInit, - targetUrl: URL, - useProxy: boolean, - onLog?: (log: MCPConnectionLog) => void -) => { fetch: typeof fetch; disable: () => void }; - -const createDiagnosticFetch = ( - config: MCPServerConfig, - onLog?: (log: MCPConnectionLog) => void, - baseInit: RequestInit = {} -) => - ( - MCPService as unknown as { createDiagnosticFetch: DiagnosticFetchFactory } - ).createDiagnosticFetch('test-server', config, baseInit, new URL(config.url), false, onLog); - -describe('MCPService', () => { - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('stops transport phase logging after handshake diagnostics are disabled', async () => { - const logs: MCPConnectionLog[] = []; - const response = new Response('{}', { - status: 200, - headers: { 'content-type': 'application/json' } - }); - - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); - - const config: MCPServerConfig = { - url: 'https://example.com/mcp', - transport: MCPTransportType.STREAMABLE_HTTP - }; - - const controller = createDiagnosticFetch(config, (log) => logs.push(log)); - - await controller.fetch(config.url, { method: 'POST', body: '{}' }); - expect(logs).toHaveLength(2); - expect(logs.every((log) => log.message.includes('https://example.com/mcp'))).toBe(true); - - controller.disable(); - await controller.fetch(config.url, { method: 'POST', body: '{}' }); - - expect(logs).toHaveLength(2); - }); - - it('redacts all configured custom headers in diagnostic request logs', async () => { - const logs: MCPConnectionLog[] = []; - const response = new Response('{}', { - status: 200, - headers: { 'content-type': 'application/json' } - }); - - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); - - const config: MCPServerConfig = { - url: 'https://example.com/mcp', - transport: MCPTransportType.STREAMABLE_HTTP, - headers: { - 'x-auth-token': 'secret-token', - 'x-vendor-api-key': 'secret-key' - } - }; - - const controller = createDiagnosticFetch(config, (log) => logs.push(log), { - headers: config.headers - }); - - await controller.fetch(config.url, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: '{}' - }); - - expect(logs).toHaveLength(2); - expect(logs[0].details).toMatchObject({ - request: { - headers: { - 'x-auth-token': '[redacted]', - 'x-vendor-api-key': '[redacted]', - 'content-type': 'application/json' - } - } - }); - }); - - it('partially redacts mcp-session-id in diagnostic request and response logs', async () => { - const logs: MCPConnectionLog[] = []; - const response = new Response('{}', { - status: 200, - headers: { - 'content-type': 'application/json', - 'mcp-session-id': 'session-response-67890' - } - }); - - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); - - const config: MCPServerConfig = { - url: 'https://example.com/mcp', - transport: MCPTransportType.STREAMABLE_HTTP - }; - - const controller = createDiagnosticFetch(config, (log) => logs.push(log)); - - await controller.fetch(config.url, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'mcp-session-id': 'session-request-12345' - }, - body: '{}' - }); - - expect(logs).toHaveLength(2); - expect(logs[0].details).toMatchObject({ - request: { - headers: { - 'content-type': 'application/json', - 'mcp-session-id': '....12345' - } - } - }); - expect(logs[1].details).toMatchObject({ - response: { - headers: { - 'content-type': 'application/json', - 'mcp-session-id': '....67890' - } - } - }); - }); - - it('extracts JSON-RPC methods without logging the raw request body', async () => { - const logs: MCPConnectionLog[] = []; - const response = new Response('{}', { - status: 200, - headers: { 'content-type': 'application/json' } - }); - - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); - - const config: MCPServerConfig = { - url: 'https://example.com/mcp', - transport: MCPTransportType.STREAMABLE_HTTP - }; - - const controller = createDiagnosticFetch(config, (log) => logs.push(log)); - - await controller.fetch(config.url, { - method: 'POST', - body: JSON.stringify([ - { jsonrpc: '2.0', id: 1, method: 'initialize' }, - { jsonrpc: '2.0', method: 'notifications/initialized' } - ]) - }); - - expect(logs[0].details).toMatchObject({ - request: { - method: 'POST', - body: { - kind: 'string', - size: expect.any(Number) - }, - jsonRpcMethods: ['initialize', 'notifications/initialized'] - } - }); - }); - - it('adds a CORS hint to Failed to fetch diagnostic log messages', async () => { - const logs: MCPConnectionLog[] = []; - const fetchError = new TypeError('Failed to fetch'); - - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(fetchError)); - - const config: MCPServerConfig = { - url: 'http://localhost:8000/mcp', - transport: MCPTransportType.STREAMABLE_HTTP - }; - - const controller = createDiagnosticFetch(config, (log) => logs.push(log)); - - await expect(controller.fetch(config.url, { method: 'POST', body: '{}' })).rejects.toThrow( - 'Failed to fetch' - ); - - expect(logs).toHaveLength(2); - expect(logs[1].message).toBe( - 'HTTP POST http://localhost:8000/mcp failed: Failed to fetch (check CORS?)' - ); - }); - - it('detaches phase error logging after the initialize handshake completes', async () => { - const phaseLogs: Array<{ phase: MCPConnectionPhase; log: MCPConnectionLog }> = []; - const stopPhaseLogging = vi.fn(); - let emitClientError: ((error: Error) => void) | undefined; - - vi.spyOn(MCPService, 'createTransport').mockReturnValue({ - transport: {} as never, - type: MCPTransportType.WEBSOCKET, - stopPhaseLogging - }); - vi.spyOn(MCPService, 'listTools').mockResolvedValue([]); - vi.spyOn(Client.prototype, 'getServerVersion').mockReturnValue(undefined); - vi.spyOn(Client.prototype, 'getServerCapabilities').mockReturnValue(undefined); - vi.spyOn(Client.prototype, 'getInstructions').mockReturnValue(undefined); - vi.spyOn(Client.prototype, 'connect').mockImplementation(async function (this: Client) { - emitClientError = (error: Error) => this.onerror?.(error); - this.onerror?.(new Error('handshake protocol error')); - }); - - await MCPService.connect( - 'test-server', - { - url: 'ws://example.com/mcp', - transport: MCPTransportType.WEBSOCKET - }, - undefined, - undefined, - (phase, log) => phaseLogs.push({ phase, log }) - ); - - expect(stopPhaseLogging).toHaveBeenCalledTimes(1); - expect( - phaseLogs.filter( - ({ phase, log }) => - phase === MCPConnectionPhase.ERROR && - log.message === 'Protocol error: handshake protocol error' - ) - ).toHaveLength(1); - - emitClientError?.(new Error('runtime protocol error')); - - expect( - phaseLogs.filter( - ({ phase, log }) => - phase === MCPConnectionPhase.ERROR && - log.message === 'Protocol error: runtime protocol error' - ) - ).toHaveLength(0); - }); -}); diff --git a/tools/server/webui/tests/unit/model-id-parser.test.ts b/tools/server/webui/tests/unit/model-id-parser.test.ts deleted file mode 100644 index 3c2937d35..000000000 --- a/tools/server/webui/tests/unit/model-id-parser.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ModelsService } from '$lib/services/models.service'; - -const { parseModelId } = ModelsService; - -describe('parseModelId', () => { - it('handles unknown patterns correctly', () => { - expect(parseModelId('model-name-1')).toStrictEqual({ - activatedParams: null, - modelName: 'model-name-1', - orgName: null, - params: null, - quantization: null, - raw: 'model-name-1', - tags: [] - }); - - expect(parseModelId('org/model-name-2')).toStrictEqual({ - activatedParams: null, - modelName: 'model-name-2', - orgName: 'org', - params: null, - quantization: null, - raw: 'org/model-name-2', - tags: [] - }); - }); - - it('extracts model parameters correctly', () => { - expect(parseModelId('model-100B-BF16')).toMatchObject({ params: '100B' }); - expect(parseModelId('model-100B:Q4_K_M')).toMatchObject({ params: '100B' }); - }); - - it('extracts model parameters correctly in lowercase', () => { - expect(parseModelId('model-100b-bf16')).toMatchObject({ params: '100B' }); - expect(parseModelId('model-100b:q4_k_m')).toMatchObject({ params: '100B' }); - }); - - it('extracts activated parameters correctly', () => { - expect(parseModelId('model-100B-A10B-BF16')).toMatchObject({ activatedParams: 'A10B' }); - expect(parseModelId('model-100B-A10B:Q4_K_M')).toMatchObject({ activatedParams: 'A10B' }); - }); - - it('extracts activated parameters correctly in lowercase', () => { - expect(parseModelId('model-100b-a10b-bf16')).toMatchObject({ activatedParams: 'A10B' }); - expect(parseModelId('model-100b-a10b:q4_k_m')).toMatchObject({ activatedParams: 'A10B' }); - }); - - it('extracts quantization correctly', () => { - // Dash-separated quantization - expect(parseModelId('model-100B-UD-IQ1_S')).toMatchObject({ quantization: 'UD-IQ1_S' }); - expect(parseModelId('model-100B-IQ4_XS')).toMatchObject({ quantization: 'IQ4_XS' }); - expect(parseModelId('model-100B-Q4_K_M')).toMatchObject({ quantization: 'Q4_K_M' }); - expect(parseModelId('model-100B-Q8_0')).toMatchObject({ quantization: 'Q8_0' }); - expect(parseModelId('model-100B-UD-Q8_K_XL')).toMatchObject({ quantization: 'UD-Q8_K_XL' }); - expect(parseModelId('model-100B-F16')).toMatchObject({ quantization: 'F16' }); - expect(parseModelId('model-100B-BF16')).toMatchObject({ quantization: 'BF16' }); - expect(parseModelId('model-100B-MXFP4')).toMatchObject({ quantization: 'MXFP4' }); - - // Colon-separated quantization - expect(parseModelId('model-100B:UD-IQ1_S')).toMatchObject({ quantization: 'UD-IQ1_S' }); - expect(parseModelId('model-100B:IQ4_XS')).toMatchObject({ quantization: 'IQ4_XS' }); - expect(parseModelId('model-100B:Q4_K_M')).toMatchObject({ quantization: 'Q4_K_M' }); - expect(parseModelId('model-100B:Q8_0')).toMatchObject({ quantization: 'Q8_0' }); - expect(parseModelId('model-100B:UD-Q8_K_XL')).toMatchObject({ quantization: 'UD-Q8_K_XL' }); - expect(parseModelId('model-100B:F16')).toMatchObject({ quantization: 'F16' }); - expect(parseModelId('model-100B:BF16')).toMatchObject({ quantization: 'BF16' }); - expect(parseModelId('model-100B:MXFP4')).toMatchObject({ quantization: 'MXFP4' }); - - // Dot-separated quantization - expect(parseModelId('nomic-embed-text-v2-moe.Q4_K_M')).toMatchObject({ - quantization: 'Q4_K_M' - }); - }); - - it('extracts additional tags correctly', () => { - expect(parseModelId('model-100B-foobar-Q4_K_M')).toMatchObject({ tags: ['foobar'] }); - expect(parseModelId('model-100B-A10B-foobar-1M-BF16')).toMatchObject({ - tags: ['foobar', '1M'] - }); - expect(parseModelId('model-100B-1M-foobar:UD-Q8_K_XL')).toMatchObject({ - tags: ['1M', 'foobar'] - }); - }); - - it('filters out container format segments from tags', () => { - expect(parseModelId('model-100B-GGUF-Instruct-BF16')).toMatchObject({ - tags: ['Instruct'] - }); - expect(parseModelId('model-100B-GGML-Instruct:Q4_K_M')).toMatchObject({ - tags: ['Instruct'] - }); - }); - - it('handles real-world examples correctly', () => { - expect(parseModelId('meta-llama/Llama-3.1-8B')).toStrictEqual({ - activatedParams: null, - modelName: 'Llama-3.1', - orgName: 'meta-llama', - params: '8B', - quantization: null, - raw: 'meta-llama/Llama-3.1-8B', - tags: [] - }); - - expect(parseModelId('openai/gpt-oss-120b-MXFP4')).toStrictEqual({ - activatedParams: null, - modelName: 'gpt-oss', - orgName: 'openai', - params: '120B', - quantization: 'MXFP4', - raw: 'openai/gpt-oss-120b-MXFP4', - tags: [] - }); - - expect(parseModelId('openai/gpt-oss-20b:Q4_K_M')).toStrictEqual({ - activatedParams: null, - modelName: 'gpt-oss', - orgName: 'openai', - params: '20B', - quantization: 'Q4_K_M', - raw: 'openai/gpt-oss-20b:Q4_K_M', - tags: [] - }); - - expect(parseModelId('Qwen/Qwen3-Coder-30B-A3B-Instruct-1M-BF16')).toStrictEqual({ - activatedParams: 'A3B', - modelName: 'Qwen3-Coder', - orgName: 'Qwen', - params: '30B', - quantization: 'BF16', - raw: 'Qwen/Qwen3-Coder-30B-A3B-Instruct-1M-BF16', - tags: ['Instruct', '1M'] - }); - }); - - it('handles real-world examples with quantization in segments', () => { - expect(parseModelId('meta-llama/Llama-4-Scout-17B-16E-Instruct-Q4_K_M')).toStrictEqual({ - activatedParams: null, - modelName: 'Llama-4-Scout', - orgName: 'meta-llama', - params: '17B', - quantization: 'Q4_K_M', - raw: 'meta-llama/Llama-4-Scout-17B-16E-Instruct-Q4_K_M', - tags: ['16E', 'Instruct'] - }); - - expect(parseModelId('MiniMaxAI/MiniMax-M2-IQ4_XS')).toStrictEqual({ - activatedParams: null, - modelName: 'MiniMax-M2', - orgName: 'MiniMaxAI', - params: null, - quantization: 'IQ4_XS', - raw: 'MiniMaxAI/MiniMax-M2-IQ4_XS', - tags: [] - }); - - expect(parseModelId('MiniMaxAI/MiniMax-M2-UD-Q3_K_XL')).toStrictEqual({ - activatedParams: null, - modelName: 'MiniMax-M2', - orgName: 'MiniMaxAI', - params: null, - quantization: 'UD-Q3_K_XL', - raw: 'MiniMaxAI/MiniMax-M2-UD-Q3_K_XL', - tags: [] - }); - - expect(parseModelId('mistralai/Devstral-2-123B-Instruct-2512-Q4_K_M')).toStrictEqual({ - activatedParams: null, - modelName: 'Devstral-2', - orgName: 'mistralai', - params: '123B', - quantization: 'Q4_K_M', - raw: 'mistralai/Devstral-2-123B-Instruct-2512-Q4_K_M', - tags: ['Instruct', '2512'] - }); - - expect(parseModelId('mistralai/Devstral-Small-2-24B-Instruct-2512-Q8_0')).toStrictEqual({ - activatedParams: null, - modelName: 'Devstral-Small-2', - orgName: 'mistralai', - params: '24B', - quantization: 'Q8_0', - raw: 'mistralai/Devstral-Small-2-24B-Instruct-2512-Q8_0', - tags: ['Instruct', '2512'] - }); - - expect(parseModelId('noctrex/GLM-4.7-Flash-MXFP4_MOE')).toStrictEqual({ - activatedParams: null, - modelName: 'GLM-4.7-Flash', - orgName: 'noctrex', - params: null, - quantization: 'MXFP4_MOE', - raw: 'noctrex/GLM-4.7-Flash-MXFP4_MOE', - tags: [] - }); - - expect(parseModelId('Qwen/Qwen3-Coder-Next-Q4_K_M')).toStrictEqual({ - activatedParams: null, - modelName: 'Qwen3-Coder-Next', - orgName: 'Qwen', - params: null, - quantization: 'Q4_K_M', - raw: 'Qwen/Qwen3-Coder-Next-Q4_K_M', - tags: [] - }); - - expect(parseModelId('openai/gpt-oss-120b-Q4_K_M')).toStrictEqual({ - activatedParams: null, - modelName: 'gpt-oss', - orgName: 'openai', - params: '120B', - quantization: 'Q4_K_M', - raw: 'openai/gpt-oss-120b-Q4_K_M', - tags: [] - }); - - expect(parseModelId('openai/gpt-oss-20b-F16')).toStrictEqual({ - activatedParams: null, - modelName: 'gpt-oss', - orgName: 'openai', - params: '20B', - quantization: 'F16', - raw: 'openai/gpt-oss-20b-F16', - tags: [] - }); - - expect(parseModelId('nomic-embed-text-v2-moe.Q4_K_M')).toStrictEqual({ - activatedParams: null, - modelName: 'nomic-embed-text-v2-moe', - orgName: null, - params: null, - quantization: 'Q4_K_M', - raw: 'nomic-embed-text-v2-moe.Q4_K_M', - tags: [] - }); - }); - - it('handles ambiguous model names', () => { - // Qwen3.5 Instruct vs Thinking — tags should distinguish them - expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Instruct')).toMatchObject({ - modelName: 'Qwen3.5', - params: '30B', - activatedParams: 'A3B', - tags: ['Instruct'] - }); - - expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Thinking')).toMatchObject({ - modelName: 'Qwen3.5', - params: '30B', - activatedParams: 'A3B', - tags: ['Thinking'] - }); - - // Dot-separated quantization with variant suffixes - expect(parseModelId('gemma-3-27b-it-heretic-v2.Q8_0')).toMatchObject({ - modelName: 'gemma-3', - params: '27B', - quantization: 'Q8_0', - tags: ['it', 'heretic', 'v2'] - }); - - expect(parseModelId('gemma-3-27b-it.Q8_0')).toMatchObject({ - modelName: 'gemma-3', - params: '27B', - quantization: 'Q8_0', - tags: ['it'] - }); - }); -}); diff --git a/tools/server/webui/tests/unit/model-names.test.ts b/tools/server/webui/tests/unit/model-names.test.ts deleted file mode 100644 index 40c5a0e3a..000000000 --- a/tools/server/webui/tests/unit/model-names.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { isValidModelName, normalizeModelName } from '$lib/utils/model-names'; - -describe('normalizeModelName', () => { - it('preserves Hugging Face org/model format (single slash)', () => { - // Single slash is treated as Hugging Face format and preserved - expect(normalizeModelName('meta-llama/Llama-3.1-8B')).toBe('meta-llama/Llama-3.1-8B'); - expect(normalizeModelName('models/model-name-1')).toBe('models/model-name-1'); - }); - - it('extracts filename from multi-segment paths', () => { - // Multiple slashes -> extract just the filename - expect(normalizeModelName('path/to/model/model-name-2')).toBe('model-name-2'); - expect(normalizeModelName('/absolute/path/to/model')).toBe('model'); - }); - - it('extracts filename from backslash paths', () => { - expect(normalizeModelName('C\\Models\\model-name-1')).toBe('model-name-1'); - expect(normalizeModelName('path\\to\\model\\model-name-2')).toBe('model-name-2'); - }); - - it('handles mixed path separators', () => { - expect(normalizeModelName('path/to\\model/model-name-2')).toBe('model-name-2'); - }); - - it('returns simple names as-is', () => { - expect(normalizeModelName('simple-model')).toBe('simple-model'); - expect(normalizeModelName('model-name-2')).toBe('model-name-2'); - }); - - it('trims whitespace', () => { - expect(normalizeModelName(' model-name ')).toBe('model-name'); - }); - - it('returns empty string for empty input', () => { - expect(normalizeModelName('')).toBe(''); - expect(normalizeModelName(' ')).toBe(''); - }); -}); - -describe('isValidModelName', () => { - it('returns true for valid names', () => { - expect(isValidModelName('model')).toBe(true); - expect(isValidModelName('path/to/model.bin')).toBe(true); - }); - - it('returns false for empty values', () => { - expect(isValidModelName('')).toBe(false); - expect(isValidModelName(' ')).toBe(false); - }); -}); diff --git a/tools/server/webui/tests/unit/reasoning-context.test.ts b/tools/server/webui/tests/unit/reasoning-context.test.ts deleted file mode 100644 index b448974a3..000000000 --- a/tools/server/webui/tests/unit/reasoning-context.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { MessageRole } from '$lib/enums'; - -/** - * Tests for the new reasoning content handling. - * In the new architecture, reasoning content is stored in a dedicated - * `reasoningContent` field on DatabaseMessage, not embedded in content with tags. - * The API sends it as `reasoning_content` on ApiChatMessageData. - */ - -describe('reasoning content in new structured format', () => { - it('reasoning is stored as separate field, not in content', () => { - // Simulate what the new chat store does - const message = { - content: 'The answer is 4.', - reasoningContent: 'Let me think: 2+2=4, basic arithmetic.' - }; - - // Content should be clean - expect(message.content).not.toContain('<<<'); - expect(message.content).toBe('The answer is 4.'); - - // Reasoning in dedicated field - expect(message.reasoningContent).toBe('Let me think: 2+2=4, basic arithmetic.'); - }); - - it('convertDbMessageToApiChatMessageData includes reasoning_content', () => { - // Simulate the conversion logic - const dbMessage = { - role: MessageRole.ASSISTANT, - content: 'The answer is 4.', - reasoningContent: 'Let me think: 2+2=4, basic arithmetic.' - }; - - const apiMessage: Record = { - role: dbMessage.role, - content: dbMessage.content - }; - if (dbMessage.reasoningContent) { - apiMessage.reasoning_content = dbMessage.reasoningContent; - } - - expect(apiMessage.content).toBe('The answer is 4.'); - expect(apiMessage.reasoning_content).toBe('Let me think: 2+2=4, basic arithmetic.'); - // No internal tags leak into either field - expect(apiMessage.content).not.toContain('<<<'); - expect(apiMessage.reasoning_content).not.toContain('<<<'); - }); - - it('API message excludes reasoning when excludeReasoningFromContext is true', () => { - const dbMessage = { - role: MessageRole.ASSISTANT, - content: 'The answer is 4.', - reasoningContent: 'internal thinking' - }; - - const excludeReasoningFromContext = true; - - const apiMessage: Record = { - role: dbMessage.role, - content: dbMessage.content - }; - if (!excludeReasoningFromContext && dbMessage.reasoningContent) { - apiMessage.reasoning_content = dbMessage.reasoningContent; - } - - expect(apiMessage.content).toBe('The answer is 4.'); - expect(apiMessage.reasoning_content).toBeUndefined(); - }); - - it('handles messages with no reasoning', () => { - const dbMessage = { - role: MessageRole.ASSISTANT, - content: 'No reasoning here.', - reasoningContent: undefined - }; - - const apiMessage: Record = { - role: dbMessage.role, - content: dbMessage.content - }; - if (dbMessage.reasoningContent) { - apiMessage.reasoning_content = dbMessage.reasoningContent; - } - - expect(apiMessage.content).toBe('No reasoning here.'); - expect(apiMessage.reasoning_content).toBeUndefined(); - }); -}); diff --git a/tools/server/webui/tests/unit/redact.test.ts b/tools/server/webui/tests/unit/redact.test.ts deleted file mode 100644 index 750296c53..000000000 --- a/tools/server/webui/tests/unit/redact.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { redactValue } from '$lib/utils/redact'; - -describe('redactValue', () => { - it('returns [redacted] by default', () => { - expect(redactValue('secret-token')).toBe('[redacted]'); - }); - - it('shows last N characters when showLastChars is provided', () => { - expect(redactValue('session-abc12', 5)).toBe('....abc12'); - }); - - it('handles value shorter than showLastChars', () => { - expect(redactValue('ab', 5)).toBe('....ab'); - }); - - it('returns [redacted] when showLastChars is 0', () => { - expect(redactValue('secret', 0)).toBe('[redacted]'); - }); -}); diff --git a/tools/server/webui/tests/unit/request-helpers.test.ts b/tools/server/webui/tests/unit/request-helpers.test.ts deleted file mode 100644 index c43252876..000000000 --- a/tools/server/webui/tests/unit/request-helpers.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - getRequestUrl, - getRequestMethod, - getRequestBody, - summarizeRequestBody, - formatDiagnosticErrorMessage, - extractJsonRpcMethods -} from '$lib/utils/request-helpers'; - -describe('getRequestUrl', () => { - it('returns a plain string input as-is', () => { - expect(getRequestUrl('https://example.com/mcp')).toBe('https://example.com/mcp'); - }); - - it('returns href from a URL object', () => { - expect(getRequestUrl(new URL('https://example.com/mcp'))).toBe('https://example.com/mcp'); - }); - - it('returns url from a Request object', () => { - const req = new Request('https://example.com/mcp'); - expect(getRequestUrl(req)).toBe('https://example.com/mcp'); - }); -}); - -describe('getRequestMethod', () => { - it('prefers method from init', () => { - expect(getRequestMethod('https://example.com', { method: 'POST' })).toBe('POST'); - }); - - it('falls back to Request.method', () => { - const req = new Request('https://example.com', { method: 'PUT' }); - expect(getRequestMethod(req)).toBe('PUT'); - }); - - it('falls back to baseInit.method', () => { - expect(getRequestMethod('https://example.com', undefined, { method: 'DELETE' })).toBe('DELETE'); - }); - - it('defaults to GET', () => { - expect(getRequestMethod('https://example.com')).toBe('GET'); - }); -}); - -describe('getRequestBody', () => { - it('returns body from init', () => { - expect(getRequestBody('https://example.com', { body: 'payload' })).toBe('payload'); - }); - - it('returns undefined when no body is present', () => { - expect(getRequestBody('https://example.com')).toBeUndefined(); - }); -}); - -describe('summarizeRequestBody', () => { - it('returns empty for null', () => { - expect(summarizeRequestBody(null)).toEqual({ kind: 'empty' }); - }); - - it('returns empty for undefined', () => { - expect(summarizeRequestBody(undefined)).toEqual({ kind: 'empty' }); - }); - - it('returns string kind with size', () => { - expect(summarizeRequestBody('hello')).toEqual({ kind: 'string', size: 5 }); - }); - - it('returns blob kind with size', () => { - const blob = new Blob(['abc']); - expect(summarizeRequestBody(blob)).toEqual({ kind: 'blob', size: 3 }); - }); - - it('returns formdata kind', () => { - expect(summarizeRequestBody(new FormData())).toEqual({ kind: 'formdata' }); - }); - - it('returns arraybuffer kind with size', () => { - expect(summarizeRequestBody(new ArrayBuffer(8))).toEqual({ kind: 'arraybuffer', size: 8 }); - }); -}); - -describe('formatDiagnosticErrorMessage', () => { - it('appends CORS hint for Failed to fetch', () => { - expect(formatDiagnosticErrorMessage(new TypeError('Failed to fetch'))).toBe( - 'Failed to fetch (check CORS?)' - ); - }); - - it('passes through other error messages unchanged', () => { - expect(formatDiagnosticErrorMessage(new Error('timeout'))).toBe('timeout'); - }); - - it('handles non-Error values', () => { - expect(formatDiagnosticErrorMessage('some string')).toBe('some string'); - }); -}); - -describe('extractJsonRpcMethods', () => { - it('extracts methods from a JSON-RPC array', () => { - const body = JSON.stringify([ - { jsonrpc: '2.0', id: 1, method: 'initialize' }, - { jsonrpc: '2.0', method: 'notifications/initialized' } - ]); - expect(extractJsonRpcMethods(body)).toEqual(['initialize', 'notifications/initialized']); - }); - - it('extracts method from a single JSON-RPC message', () => { - const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); - expect(extractJsonRpcMethods(body)).toEqual(['tools/list']); - }); - - it('returns undefined for non-string body', () => { - expect(extractJsonRpcMethods(null)).toBeUndefined(); - expect(extractJsonRpcMethods(undefined)).toBeUndefined(); - }); - - it('returns undefined for invalid JSON', () => { - expect(extractJsonRpcMethods('not json')).toBeUndefined(); - }); - - it('returns undefined when no methods found', () => { - expect(extractJsonRpcMethods(JSON.stringify({ foo: 'bar' }))).toBeUndefined(); - }); -}); diff --git a/tools/server/webui/tests/unit/sanitize-headers.test.ts b/tools/server/webui/tests/unit/sanitize-headers.test.ts deleted file mode 100644 index f5a682d86..000000000 --- a/tools/server/webui/tests/unit/sanitize-headers.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { sanitizeHeaders } from '$lib/utils/api-headers'; - -describe('sanitizeHeaders', () => { - it('returns empty object for undefined input', () => { - expect(sanitizeHeaders()).toEqual({}); - }); - - it('passes through non-sensitive headers', () => { - const headers = new Headers({ 'content-type': 'application/json', accept: 'text/html' }); - expect(sanitizeHeaders(headers)).toEqual({ - 'content-type': 'application/json', - accept: 'text/html' - }); - }); - - it('redacts known sensitive headers', () => { - const headers = new Headers({ - authorization: 'Bearer secret', - 'x-api-key': 'key-123', - 'content-type': 'application/json' - }); - const result = sanitizeHeaders(headers); - expect(result.authorization).toBe('[redacted]'); - expect(result['x-api-key']).toBe('[redacted]'); - expect(result['content-type']).toBe('application/json'); - }); - - it('partially redacts headers specified in partialRedactHeaders', () => { - const headers = new Headers({ 'mcp-session-id': 'session-12345' }); - const partial = new Map([['mcp-session-id', 5]]); - expect(sanitizeHeaders(headers, undefined, partial)['mcp-session-id']).toBe('....12345'); - }); - - it('fully redacts mcp-session-id when no partialRedactHeaders is given', () => { - const headers = new Headers({ 'mcp-session-id': 'session-12345' }); - expect(sanitizeHeaders(headers)['mcp-session-id']).toBe('[redacted]'); - }); - - it('redacts extra headers provided by the caller', () => { - const headers = new Headers({ - 'x-vendor-key': 'vendor-secret', - 'content-type': 'application/json' - }); - const result = sanitizeHeaders(headers, ['x-vendor-key']); - expect(result['x-vendor-key']).toBe('[redacted]'); - expect(result['content-type']).toBe('application/json'); - }); - - it('handles case-insensitive extra header names', () => { - const headers = new Headers({ 'X-Custom-Token': 'token-value' }); - const result = sanitizeHeaders(headers, ['X-CUSTOM-TOKEN']); - expect(result['x-custom-token']).toBe('[redacted]'); - }); -}); diff --git a/tools/server/webui/tests/unit/uri-template.test.ts b/tools/server/webui/tests/unit/uri-template.test.ts deleted file mode 100644 index 622127923..000000000 --- a/tools/server/webui/tests/unit/uri-template.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - extractTemplateVariables, - expandTemplate, - isTemplateComplete, - normalizeResourceUri -} from '../../src/lib/utils/uri-template'; -import { URI_TEMPLATE_OPERATORS } from '../../src/lib/constants/uri-template'; - -describe('extractTemplateVariables', () => { - it('extracts simple variables', () => { - const vars = extractTemplateVariables('file:///{path}'); - expect(vars).toEqual([{ name: 'path', operator: '' }]); - }); - - it('extracts multiple variables', () => { - const vars = extractTemplateVariables('db://{schema}/{table}'); - expect(vars).toEqual([ - { name: 'schema', operator: '' }, - { name: 'table', operator: '' } - ]); - }); - - it('extracts variables with operators', () => { - const vars = extractTemplateVariables('http://example.com{+path}'); - expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_OPERATORS.RESERVED }]); - }); - - it('extracts comma-separated variable lists', () => { - const vars = extractTemplateVariables('{x,y,z}'); - expect(vars).toEqual([ - { name: 'x', operator: '' }, - { name: 'y', operator: '' }, - { name: 'z', operator: '' } - ]); - }); - - it('deduplicates variable names', () => { - const vars = extractTemplateVariables('{name}/{name}'); - expect(vars).toEqual([{ name: 'name', operator: '' }]); - }); - - it('handles fragment expansion', () => { - const vars = extractTemplateVariables('http://example.com/page{#section}'); - expect(vars).toEqual([{ name: 'section', operator: URI_TEMPLATE_OPERATORS.FRAGMENT }]); - }); - - it('handles path segment expansion', () => { - const vars = extractTemplateVariables('http://example.com{/path}'); - expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_OPERATORS.PATH_SEGMENT }]); - }); - - it('returns empty array for template without variables', () => { - const vars = extractTemplateVariables('http://example.com/static'); - expect(vars).toEqual([]); - }); - - it('strips explode modifier', () => { - const vars = extractTemplateVariables('{list*}'); - expect(vars).toEqual([{ name: 'list', operator: '' }]); - }); - - it('strips prefix modifier', () => { - const vars = extractTemplateVariables('{value:5}'); - expect(vars).toEqual([{ name: 'value', operator: '' }]); - }); -}); - -describe('expandTemplate', () => { - it('expands simple variable', () => { - const result = expandTemplate('file:///{path}', { path: 'src/main.rs' }); - expect(result).toBe('file:///src%2Fmain.rs'); - }); - - it('expands reserved variable (no encoding)', () => { - const result = expandTemplate('file:///{+path}', { path: 'src/main.rs' }); - expect(result).toBe('file:///src/main.rs'); - }); - - it('expands multiple variables', () => { - const result = expandTemplate('db://{schema}/{table}', { - schema: 'public', - table: 'users' - }); - expect(result).toBe('db://public/users'); - }); - - it('leaves empty for missing variables', () => { - const result = expandTemplate('{missing}', {}); - expect(result).toBe(''); - }); - - it('expands fragment', () => { - const result = expandTemplate('http://example.com/page{#section}', { - section: 'intro' - }); - expect(result).toBe('http://example.com/page#intro'); - }); - - it('expands path segments', () => { - const result = expandTemplate('http://example.com{/path}', { path: 'docs' }); - expect(result).toBe('http://example.com/docs'); - }); - - it('expands query parameters', () => { - const result = expandTemplate('http://example.com{?q}', { q: 'search term' }); - expect(result).toBe('http://example.com?q=search%20term'); - }); - - it('keeps static parts unchanged', () => { - const result = expandTemplate('http://example.com/static', {}); - expect(result).toBe('http://example.com/static'); - }); -}); - -describe('isTemplateComplete', () => { - it('returns true when all variables are filled', () => { - expect(isTemplateComplete('file:///{path}', { path: 'test.txt' })).toBe(true); - }); - - it('returns false when a variable is missing', () => { - expect(isTemplateComplete('db://{schema}/{table}', { schema: 'public' })).toBe(false); - }); - - it('returns false when a variable is empty', () => { - expect(isTemplateComplete('file:///{path}', { path: '' })).toBe(false); - }); - - it('returns false when a variable is whitespace only', () => { - expect(isTemplateComplete('file:///{path}', { path: ' ' })).toBe(false); - }); - - it('returns true for template without variables', () => { - expect(isTemplateComplete('http://example.com/static', {})).toBe(true); - }); - - it('returns true when all multiple variables are filled', () => { - expect(isTemplateComplete('db://{schema}/{table}', { schema: 'public', table: 'users' })).toBe( - true - ); - }); -}); - -describe('normalizeResourceUri', () => { - it('passes through a normal URI unchanged', () => { - expect(normalizeResourceUri('svelte://svelte/$effect.md')).toBe('svelte://svelte/$effect.md'); - }); - - it('normalizes triple-slash URIs from path-style template expansion', () => { - expect(normalizeResourceUri('svelte:///svelte/$effect.md')).toBe('svelte://svelte/$effect.md'); - }); - - it('normalizes quadruple-slash URIs', () => { - expect(normalizeResourceUri('svelte:////svelte/$effect.md')).toBe('svelte://svelte/$effect.md'); - }); - - it('handles file:// URIs', () => { - expect(normalizeResourceUri('file:///home/user/doc.txt')).toBe('file://home/user/doc.txt'); - }); - - it('handles http URIs unchanged', () => { - expect(normalizeResourceUri('http://example.com/path')).toBe('http://example.com/path'); - }); - - it('returns non-URI strings unchanged', () => { - expect(normalizeResourceUri('not-a-uri')).toBe('not-a-uri'); - }); -}); diff --git a/tools/server/webui/tsconfig.json b/tools/server/webui/tsconfig.json deleted file mode 100644 index 7c585f4db..000000000 --- a/tools/server/webui/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "extends": "./.svelte-kit/tsconfig.json", - "compilerOptions": { - "allowJs": true, - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true, - "moduleResolution": "bundler" - }, - "include": [ - ".svelte-kit/ambient.d.ts", - ".svelte-kit/non-ambient.d.ts", - ".svelte-kit/types/**/$types.d.ts", - "vite.config.js", - "vite.config.ts", - "src/**/*.js", - "src/**/*.ts", - "src/**/*.svelte", - "tests/**/*.ts", - "tests/**/*.svelte", - ".storybook/**/*.ts", - ".storybook/**/*.svelte" - ] - // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias - // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files - // - // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes - // from the referenced tsconfig.json - TypeScript does not merge them in -} diff --git a/tools/server/webui/vite.config.ts b/tools/server/webui/vite.config.ts deleted file mode 100644 index d3db24bf2..000000000 --- a/tools/server/webui/vite.config.ts +++ /dev/null @@ -1,105 +0,0 @@ -import tailwindcss from '@tailwindcss/vite'; -import { sveltekit } from '@sveltejs/kit/vite'; -import { dirname, resolve } from 'path'; -import { fileURLToPath } from 'url'; - -import { defineConfig, searchForWorkspaceRoot } from 'vite'; -import devtoolsJson from 'vite-plugin-devtools-json'; -import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; -import { llamaCppBuildPlugin } from './scripts/vite-plugin-llama-cpp-build'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default defineConfig({ - resolve: { - alias: { - 'katex-fonts': resolve('node_modules/katex/dist/fonts') - } - }, - - build: { - assetsInlineLimit: 32000, - chunkSizeWarningLimit: 3072, - minify: true - }, - - css: { - preprocessorOptions: { - scss: { - additionalData: ` - $use-woff2: true; - $use-woff: false; - $use-ttf: false; - ` - } - } - }, - - plugins: [tailwindcss(), sveltekit(), devtoolsJson(), llamaCppBuildPlugin()], - - test: { - projects: [ - { - extends: './vite.config.ts', - test: { - name: 'client', - environment: 'browser', - browser: { - enabled: true, - provider: 'playwright', - instances: [{ browser: 'chromium' }] - }, - include: ['tests/client/**/*.svelte.{test,spec}.{js,ts}'], - setupFiles: ['./vitest-setup-client.ts'] - } - }, - - { - extends: './vite.config.ts', - test: { - name: 'unit', - environment: 'node', - include: ['tests/unit/**/*.{test,spec}.{js,ts}'] - } - }, - - { - extends: './vite.config.ts', - test: { - name: 'ui', - environment: 'browser', - browser: { - enabled: true, - provider: 'playwright', - instances: [{ browser: 'chromium', headless: true }] - }, - include: ['tests/stories/**/*.stories.{js,ts,svelte}'], - setupFiles: ['./.storybook/vitest.setup.ts'] - }, - plugins: [ - storybookTest({ - storybookScript: 'pnpm run storybook --no-open' - }) - ] - } - ] - }, - - server: { - proxy: { - '/v1': 'http://localhost:8080', - '/props': 'http://localhost:8080', - '/models': 'http://localhost:8080', - '/tools': 'http://localhost:8080', - '/slots': 'http://localhost:8080', - '/cors-proxy': 'http://localhost:8080' - }, - headers: { - 'Cross-Origin-Embedder-Policy': 'require-corp', - 'Cross-Origin-Opener-Policy': 'same-origin' - }, - fs: { - allow: [searchForWorkspaceRoot(process.cwd()), resolve(__dirname, 'tests')] - } - } -}); diff --git a/tools/server/webui/vitest-setup-client.ts b/tools/server/webui/vitest-setup-client.ts deleted file mode 100644 index 570b9f0e1..000000000 --- a/tools/server/webui/vitest-setup-client.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// diff --git a/tools/ui/.gitignore b/tools/ui/.gitignore new file mode 100644 index 000000000..051d884b0 --- /dev/null +++ b/tools/ui/.gitignore @@ -0,0 +1,28 @@ +test-results +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +*storybook.log +storybook-static +*.code-workspace \ No newline at end of file diff --git a/tools/ui/.npmrc b/tools/ui/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/tools/ui/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/tools/ui/.prettierignore b/tools/ui/.prettierignore new file mode 100644 index 000000000..7d74fe246 --- /dev/null +++ b/tools/ui/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/tools/ui/.prettierrc b/tools/ui/.prettierrc new file mode 100644 index 000000000..8103a0b5d --- /dev/null +++ b/tools/ui/.prettierrc @@ -0,0 +1,16 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ], + "tailwindStylesheet": "./src/app.css" +} diff --git a/tools/ui/.storybook/decorators/ModeWatcherDecorator.svelte b/tools/ui/.storybook/decorators/ModeWatcherDecorator.svelte new file mode 100644 index 000000000..8bded8b3f --- /dev/null +++ b/tools/ui/.storybook/decorators/ModeWatcherDecorator.svelte @@ -0,0 +1,36 @@ + + + + +{#if children} + {@const Component = children} + + +{/if} diff --git a/tools/ui/.storybook/decorators/TooltipProviderDecorator.svelte b/tools/ui/.storybook/decorators/TooltipProviderDecorator.svelte new file mode 100644 index 000000000..ba0cabc56 --- /dev/null +++ b/tools/ui/.storybook/decorators/TooltipProviderDecorator.svelte @@ -0,0 +1,13 @@ + + + + {@render children()} + diff --git a/tools/ui/.storybook/main.ts b/tools/ui/.storybook/main.ts new file mode 100644 index 000000000..4f6945f21 --- /dev/null +++ b/tools/ui/.storybook/main.ts @@ -0,0 +1,24 @@ +import type { StorybookConfig } from '@storybook/sveltekit'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const config: StorybookConfig = { + stories: ['../tests/stories/**/*.mdx', '../tests/stories/**/*.stories.@(js|ts|svelte)'], + addons: [ + '@storybook/addon-svelte-csf', + '@chromatic-com/storybook', + '@storybook/addon-vitest', + '@storybook/addon-a11y', + '@storybook/addon-docs' + ], + framework: '@storybook/sveltekit', + viteFinal: async (config) => { + config.server = config.server || {}; + config.server.fs = config.server.fs || {}; + config.server.fs.allow = [...(config.server.fs.allow || []), resolve(__dirname, '../tests')]; + return config; + } +}; +export default config; diff --git a/tools/ui/.storybook/preview.ts b/tools/ui/.storybook/preview.ts new file mode 100644 index 000000000..4610229a6 --- /dev/null +++ b/tools/ui/.storybook/preview.ts @@ -0,0 +1,42 @@ +import type { Preview } from '@storybook/sveltekit'; +import '../src/app.css'; +import ModeWatcherDecorator from './decorators/ModeWatcherDecorator.svelte'; +import TooltipProviderDecorator from './decorators/TooltipProviderDecorator.svelte'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i + } + }, + + backgrounds: { + disabled: true + }, + + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo' + } + }, + decorators: [ + (story) => ({ + Component: ModeWatcherDecorator, + props: { + children: story + } + }), + (story) => ({ + Component: TooltipProviderDecorator, + props: { + children: story + } + }) + ] +}; + +export default preview; diff --git a/tools/ui/.storybook/vitest.setup.ts b/tools/ui/.storybook/vitest.setup.ts new file mode 100644 index 000000000..147157289 --- /dev/null +++ b/tools/ui/.storybook/vitest.setup.ts @@ -0,0 +1,12 @@ +import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; +import { setProjectAnnotations } from '@storybook/sveltekit'; +import * as previewAnnotations from './preview'; +import { beforeAll } from 'vitest'; + +const project = setProjectAnnotations([a11yAddonAnnotations, previewAnnotations]); + +beforeAll(async () => { + if (project.beforeAll) { + await project.beforeAll(); + } +}); diff --git a/tools/ui/CMakeLists.txt b/tools/ui/CMakeLists.txt new file mode 100644 index 000000000..9687ca92e --- /dev/null +++ b/tools/ui/CMakeLists.txt @@ -0,0 +1,157 @@ +set(TARGET llama-ui) + +# Deprecated: use LLAMA_UI_HF_BUCKET instead +set(LLAMA_WEBUI_HF_BUCKET "llama-ui" CACHE STRING "Hugging Face bucket name for prebuilt webui assets (deprecated: use LLAMA_UI_HF_BUCKET)") +set(LLAMA_UI_HF_BUCKET "llama-ui" CACHE STRING "Hugging Face bucket name for prebuilt UI assets") + +# Backward compat: forward old var to new one +if(DEFINED LLAMA_WEBUI_HF_BUCKET AND NOT DEFINED LLAMA_UI_HF_BUCKET) + set(LLAMA_UI_HF_BUCKET ${LLAMA_WEBUI_HF_BUCKET}) +elseif(DEFINED LLAMA_WEBUI_HF_BUCKET AND NOT "${LLAMA_WEBUI_HF_BUCKET}" STREQUAL "${LLAMA_UI_HF_BUCKET}") + message(DEPRECATION "LLAMA_WEBUI_HF_BUCKET is deprecated, use LLAMA_UI_HF_BUCKET instead") +endif() + +set(TARGET_SRCS "") +set(UI_COMPILE_DEFS "") + +# Support both old (LLAMA_BUILD_WEBUI) and new (LLAMA_BUILD_UI) option names +if(LLAMA_BUILD_WEBUI OR LLAMA_BUILD_UI) + if(LLAMA_BUILD_WEBUI AND NOT LLAMA_BUILD_UI) + message(DEPRECATION "LLAMA_BUILD_WEBUI is deprecated, use LLAMA_BUILD_UI instead") + endif() + + set(PUBLIC_ASSETS + index.html + bundle.js + bundle.css + loading.html + ) + + # Determine source of UI assets (priority: local > HF Bucket) + set(UI_SOURCE "") + set(UI_SOURCE_DIR "") + + # Priority 1: Check for local build output + set(LOCAL_UI_DIR "${PROJECT_SOURCE_DIR}/build/tools/ui/dist") + + # Verify all required assets exist before declaring local source valid + set(ALL_ASSETS_PRESENT TRUE) + foreach(asset ${PUBLIC_ASSETS}) + if(NOT EXISTS "${LOCAL_UI_DIR}/${asset}") + set(ALL_ASSETS_PRESENT FALSE) + break() + endif() + endforeach() + + if(ALL_ASSETS_PRESENT) + set(UI_SOURCE "local") + set(UI_SOURCE_DIR "${LOCAL_UI_DIR}") + message(STATUS "UI: using local build from ${UI_SOURCE_DIR}") + endif() + + # Priority 2: Build-time asset provisioning (npm build → HF Bucket fallback) + if(NOT UI_SOURCE_DIR) + # Environment variable takes precedence (e.g., from CI workflows) + # Deprecated: use HF_UI_VERSION instead + if(DEFINED ENV{HF_WEBUI_VERSION}) + set(HF_UI_VERSION "$ENV{HF_WEBUI_VERSION}") + message(DEPRECATION "HF_WEBUI_VERSION env var is deprecated, use HF_UI_VERSION instead") + if(NOT HF_UI_VERSION MATCHES "^[A-Za-z0-9._-]+$") + message(FATAL_ERROR "UI: invalid HF_WEBUI_VERSION='${HF_UI_VERSION}' - must match ^[A-Za-z0-9._-]+$") + endif() + elseif(DEFINED ENV{HF_UI_VERSION}) + set(HF_UI_VERSION "$ENV{HF_UI_VERSION}") + if(NOT HF_UI_VERSION MATCHES "^[A-Za-z0-9._-]+$") + message(FATAL_ERROR "UI: invalid HF_UI_VERSION='${HF_UI_VERSION}' - must match ^[A-Za-z0-9._-]+$") + endif() + elseif(DEFINED LLAMA_BUILD_NUMBER) + set(HF_UI_VERSION "b${LLAMA_BUILD_NUMBER}") + message(STATUS "UI: derived HF_UI_VERSION=b${LLAMA_BUILD_NUMBER}") + else() + set(HF_UI_VERSION "") + message(STATUS "UI: version not specified (will use HF 'latest')") + endif() + + if("${HF_UI_VERSION}" STREQUAL "") + set(UI_VERSION_TAG "provisioned") + else() + set(UI_VERSION_TAG "${HF_UI_VERSION}") + endif() + set(UI_STAMP "${CMAKE_CURRENT_BINARY_DIR}/.ui-${UI_VERSION_TAG}.stamp") + + string(REPLACE ";" "+" PUBLIC_ASSETS_JOINED "${PUBLIC_ASSETS}") + + add_custom_command( + OUTPUT ${UI_STAMP} + COMMAND ${CMAKE_COMMAND} + "-DSOURCE_DIR=${PROJECT_SOURCE_DIR}" + "-DPUBLIC_DIR=${PROJECT_SOURCE_DIR}/build/tools/ui/dist" + "-DHF_BUCKET=${LLAMA_UI_HF_BUCKET}" + "-DHF_VERSION=${HF_UI_VERSION}" + "-DHF_ENABLED=${LLAMA_USE_PREBUILT_UI}" + "-DASSETS=${PUBLIC_ASSETS_JOINED}" + "-DSTAMP_FILE=${UI_STAMP}" + "-DNPM_DIR=${PROJECT_SOURCE_DIR}/tools/ui" + -P ${PROJECT_SOURCE_DIR}/scripts/ui-download.cmake + COMMENT "Building/provisioning UI assets (npm build -> HF Bucket fallback)" + ) + + set(UI_SOURCE "provisioned") + set(UI_SOURCE_DIR "${PROJECT_SOURCE_DIR}/build/tools/ui/dist") + endif() + + # Process assets from the determined source + if(UI_SOURCE_DIR) + foreach(asset ${PUBLIC_ASSETS}) + set(input "${UI_SOURCE_DIR}/${asset}") + set(output "${CMAKE_CURRENT_BINARY_DIR}/${asset}.hpp") + list(APPEND TARGET_SRCS ${output}) + + if(UI_SOURCE STREQUAL "local") + if(NOT EXISTS "${input}") + message(FATAL_ERROR "UI asset not found: ${input}") + endif() + set(dependency "${input}") + else() + set(dependency "${UI_STAMP}") + endif() + + add_custom_command( + DEPENDS ${dependency} + OUTPUT "${output}" + COMMAND "${CMAKE_COMMAND}" "-DINPUT=${input}" "-DOUTPUT=${output}" -P "${PROJECT_SOURCE_DIR}/scripts/xxd.cmake" + ) + set_source_files_properties(${output} PROPERTIES GENERATED TRUE) + endforeach() + + list(APPEND UI_COMPILE_DEFS + LLAMA_BUILD_WEBUI # Deprecated: use LLAMA_BUILD_UI + LLAMA_BUILD_UI + LLAMA_WEBUI_DEFAULT_ENABLED=1 # Deprecated: use LLAMA_UI_DEFAULT_ENABLED + LLAMA_UI_DEFAULT_ENABLED=1 + ) + message(STATUS "UI: embedded with source: ${UI_SOURCE}") + else() + message(WARNING "UI: no source available. Neither local build (build/tools/ui/dist/) nor HF Bucket download succeeded.") + message(WARNING "UI: building server without embedded UI. Set LLAMA_BUILD_UI=OFF to suppress this warning.") + list(APPEND UI_COMPILE_DEFS LLAMA_WEBUI_DEFAULT_ENABLED=0 LLAMA_UI_DEFAULT_ENABLED=0) + endif() +else() + list(APPEND UI_COMPILE_DEFS LLAMA_WEBUI_DEFAULT_ENABLED=0 LLAMA_UI_DEFAULT_ENABLED=0) +endif() + +# Build the static library +add_library(${TARGET} STATIC ui.cpp) + +target_include_directories(${TARGET} PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_BINARY_DIR} +) + +target_compile_definitions(${TARGET} PUBLIC ${UI_COMPILE_DEFS}) + +if(TARGET_SRCS) + # List generated .hpp files as sources so CMake tracks them as build dependencies + target_sources(${TARGET} PRIVATE ${TARGET_SRCS}) + set_source_files_properties(${TARGET_SRCS} PROPERTIES HEADER_FILE_ONLY TRUE) +endif() diff --git a/tools/ui/README.md b/tools/ui/README.md new file mode 100644 index 000000000..abbbabe92 --- /dev/null +++ b/tools/ui/README.md @@ -0,0 +1,686 @@ +# llama-ui + +A modern, feature-rich web interface for llama-server built with SvelteKit. This UI provides an intuitive chat interface with advanced file handling, conversation management, and comprehensive model interaction capabilities. + +Llama UI supports two server operation modes: + +- **MODEL mode** - Single model operation (standard llama-server) +- **ROUTER mode** - Multi-model operation with dynamic model loading/unloading + +--- + +## Table of Contents + +- [Features](#features) +- [Getting Started](#getting-started) +- [Tech Stack](#tech-stack) +- [Build Pipeline](#build-pipeline) +- [Architecture](#architecture) +- [Data Flows](#data-flows) +- [Architectural Patterns](#architectural-patterns) +- [Testing](#testing) + +--- + +## Features + +### Chat Interface + +- **Streaming responses** with real-time updates +- **Reasoning content** - Support for models with thinking/reasoning blocks +- **Dark/light theme** with system preference detection +- **Responsive design** for desktop and mobile + +### File Attachments + +- **Images** - JPEG, PNG, GIF, WebP, SVG (with PNG conversion) +- **Documents** - PDF (text extraction or image conversion for vision models) +- **Audio** - MP3, WAV for audio-capable models +- **Text files** - Source code, markdown, and other text formats +- **Drag-and-drop** and paste support with rich previews + +### Conversation Management + +- **Branching** - Branch messages conversations at any point by editing messages or regenerating responses, navigate between branches +- **Regeneration** - Regenerate responses with optional model switching (ROUTER mode) +- **Import/Export** - JSON format for backup and sharing +- **Search** - Find conversations by title or content + +### Advanced Rendering + +- **Syntax highlighting** - Code blocks with language detection +- **Math formulas** - KaTeX rendering for LaTeX expressions +- **Markdown** - Full GFM support with tables, lists, and more + +### Multi-Model Support (ROUTER mode) + +- **Model selector** with Loaded/Available groups +- **Automatic loading** - Models load on selection +- **Modality validation** - Prevents sending images to non-vision models +- **LRU unloading** - Server auto-manages model cache + +### Keyboard Shortcuts + +| Shortcut | Action | +| ------------------ | -------------------- | +| `Shift+Ctrl/Cmd+O` | New chat | +| `Shift+Ctrl/Cmd+E` | Edit conversation | +| `Shift+Ctrl/Cmd+D` | Delete conversation | +| `Ctrl/Cmd+K` | Search conversations | +| `Ctrl/Cmd+B` | Toggle sidebar | + +### Developer Experience + +- **Request tracking** - Monitor token generation with `/slots` endpoint +- **Storybook** - Component library with visual testing +- **Hot reload** - Instant updates during development + +--- + +## Getting Started + +### Prerequisites + +- **Node.js** 18+ (20+ recommended) +- **npm** 9+ +- **llama-server** running locally (for API access) + +### 1. Install Dependencies + +```bash +cd tools/ui +npm install +``` + +### 2. Start llama-server + +In a separate terminal, start the backend server: + +```bash +# Single model (MODEL mode) +./llama-server -m model.gguf + +# Multi-model (ROUTER mode) +./llama-server --models-dir /path/to/models +``` + +### 3. Start Development Servers + +```bash +npm run dev +``` + +This starts: + +- **Vite dev server** at `http://localhost:5173` - The main UI frontend app +- **Storybook** at `http://localhost:6006` - Component documentation + +The Vite dev server proxies API requests to `http://localhost:8080` (default llama-server port): + +```typescript +// vite.config.ts proxy configuration +proxy: { + '/v1': 'http://localhost:8080', + '/props': 'http://localhost:8080', + '/slots': 'http://localhost:8080', + '/models': 'http://localhost:8080' +} +``` + +### Development Workflow + +1. Open `http://localhost:5173` in your browser +2. Make changes to `.svelte`, `.ts`, or `.css` files +3. Changes hot-reload instantly +4. Use Storybook at `http://localhost:6006` for isolated component development + +--- + +## Tech Stack + +| Layer | Technology | Purpose | +| ----------------- | ------------------------------- | -------------------------------------------------------- | +| **Framework** | SvelteKit + Svelte 5 | Reactive UI with runes (`$state`, `$derived`, `$effect`) | +| **UI Components** | shadcn-svelte + bits-ui | Accessible, customizable component library | +| **Styling** | TailwindCSS 4 | Utility-first CSS with design tokens | +| **Database** | IndexedDB (Dexie) | Client-side storage for conversations and messages | +| **Build** | Vite | Fast bundling with static adapter | +| **Testing** | Playwright + Vitest + Storybook | E2E, unit, and visual testing | +| **Markdown** | remark + rehype | Markdown processing with KaTeX and syntax highlighting | + +### Key Dependencies + +```json +{ + "svelte": "^5.0.0", + "bits-ui": "^2.8.11", + "dexie": "^4.0.11", + "pdfjs-dist": "^5.4.54", + "highlight.js": "^11.11.1", + "rehype-katex": "^7.0.1" +} +``` + +--- + +## Build Pipeline + +### Development Build + +```bash +npm run dev +``` + +Runs Vite in development mode with: + +- Hot Module Replacement (HMR) +- Source maps +- Proxy to llama-server + +### Production Build + +```bash +npm run build +``` + +The build process: + +1. **Vite Build** - Bundles all TypeScript, Svelte, and CSS +2. **Static Adapter** - Outputs to `../../build/tools/ui/dist` (llama-server's static file directory) +3. **Post-Build Script** - Cleans up intermediate files +4. **Custom Plugin** - Creates `index.html` with: + - Inlined favicon as base64 + - GZIP compression (level 9) + - Deterministic output (zeroed timestamps) + +```text +tools/ui/ → build → build/tools/ui/dist/ +├── src/ ├── index.html (served by llama-server) +├── static/ └── (favicon inlined) +└── ... +``` + +### SvelteKit Configuration + +```javascript +// svelte.config.js +adapter: adapter({ + pages: '../../build/tools/ui/dist', // Output directory + assets: '../../build/tools/ui/dist', // Static assets + fallback: 'index.html', // SPA fallback + strict: true +}), +output: { + bundleStrategy: 'inline' // Single-file bundle +} +``` + +### Integration with llama-server + +llama-ui is embedded directly into the llama-server binary: + +1. `npm run build` outputs `index.html` to `build/tools/ui/dist/` +2. llama-server compiles this into the binary at build time +3. When accessing `/`, llama-server serves the bundled HTML + +This results in a **single portable binary** with the full Llama UI included. + +--- + +## Architecture + +Llama UI follows a layered architecture with unidirectional data flow: + +```text +Routes → Components → Hooks → Stores → Services → Storage/API +``` + +### High-Level Architecture + +See: [`docs/architecture/high-level-architecture-simplified.md`](docs/architecture/high-level-architecture-simplified.md) + +```mermaid +flowchart TB + subgraph Routes["📍 Routes"] + R1["/ (Welcome)"] + R2["/chat/[id]"] + RL["+layout.svelte"] + end + + subgraph Components["🧩 Components"] + C_Sidebar["ChatSidebar"] + C_Screen["ChatScreen"] + C_Form["ChatForm"] + C_Messages["ChatMessages"] + C_ModelsSelector["ModelsSelector"] + C_Settings["ChatSettings"] + end + + subgraph Stores["🗄️ Stores"] + S1["chatStore"] + S2["conversationsStore"] + S3["modelsStore"] + S4["serverStore"] + S5["settingsStore"] + end + + subgraph Services["⚙️ Services"] + SV1["ChatService"] + SV2["ModelsService"] + SV3["PropsService"] + SV4["DatabaseService"] + end + + subgraph Storage["💾 Storage"] + ST1["IndexedDB"] + ST2["LocalStorage"] + end + + subgraph APIs["🌐 llama-server"] + API1["/v1/chat/completions"] + API2["/props"] + API3["/models/*"] + end + + R1 & R2 --> C_Screen + RL --> C_Sidebar + C_Screen --> C_Form & C_Messages & C_Settings + C_Screen --> S1 & S2 + C_ModelsSelector --> S3 & S4 + S1 --> SV1 & SV4 + S3 --> SV2 & SV3 + SV4 --> ST1 + SV1 --> API1 + SV2 --> API3 + SV3 --> API2 +``` + +### Layer Breakdown + +#### Routes (`src/routes/`) + +- **`/`** - Welcome screen, creates new conversation +- **`/chat/[id]`** - Active chat interface +- **`+layout.svelte`** - Sidebar, navigation, global initialization + +#### Components (`src/lib/components/`) + +Components are organized in `app/` (application-specific) and `ui/` (shadcn-svelte primitives). + +**Chat Components** (`app/chat/`): + +| Component | Responsibility | +| ------------------ | --------------------------------------------------------------------------- | +| `ChatScreen/` | Main chat container, coordinates message list, input form, and attachments | +| `ChatForm/` | Message input textarea with file upload, paste handling, keyboard shortcuts | +| `ChatMessages/` | Message list with branch navigation, regenerate/continue/edit actions | +| `ChatAttachments/` | File attachment previews, drag-and-drop, PDF/image/audio handling | +| `ChatSettings/` | Parameter sliders (temperature, top-p, etc.) with server default sync | +| `ChatSidebar/` | Conversation list, search, import/export, navigation | + +**Dialog Components** (`app/dialogs/`): + +| Component | Responsibility | +| ------------------------------- | -------------------------------------------------------- | +| `DialogChatSettings` | Full-screen settings configuration | +| `DialogModelInformation` | Model details (context size, modalities, parallel slots) | +| `DialogChatAttachmentPreview` | Full preview for images, PDFs (text or page view), code | +| `DialogConfirmation` | Generic confirmation for destructive actions | +| `DialogConversationTitleUpdate` | Edit conversation title | + +**Server/Model Components** (`app/server/`, `app/models/`): + +| Component | Responsibility | +| ------------------- | --------------------------------------------------------- | +| `ServerErrorSplash` | Error display when server is unreachable | +| `ModelsSelector` | Model dropdown with Loaded/Available groups (ROUTER mode) | + +**Shared UI Components** (`app/misc/`): + +| Component | Responsibility | +| -------------------------------- | ---------------------------------------------------------------- | +| `MarkdownContent` | Markdown rendering with KaTeX, syntax highlighting, copy buttons | +| `SyntaxHighlightedCode` | Code blocks with language detection and highlighting | +| `ActionButton`, `ActionDropdown` | Reusable action buttons and menus | +| `BadgeModality`, `BadgeInfo` | Status and capability badges | + +#### Hooks (`src/lib/hooks/`) + +- **`useModelChangeValidation`** - Validates model switch against conversation modalities +- **`useProcessingState`** - Tracks streaming progress and token generation + +#### Stores (`src/lib/stores/`) + +| Store | Responsibility | +| -------------------- | --------------------------------------------------------- | +| `chatStore` | Message sending, streaming, abort control, error handling | +| `conversationsStore` | CRUD for conversations, message branching, navigation | +| `modelsStore` | Model list, selection, loading/unloading (ROUTER) | +| `serverStore` | Server properties, role detection, modalities | +| `settingsStore` | User preferences, parameter sync with server defaults | + +#### Services (`src/lib/services/`) + +| Service | Responsibility | +| ---------------------- | ----------------------------------------------- | +| `ChatService` | API calls to`/v1/chat/completions`, SSE parsing | +| `ModelsService` | `/models`, `/models/load`, `/models/unload` | +| `PropsService` | `/props`, `/props?model=` | +| `DatabaseService` | IndexedDB operations via Dexie | +| `ParameterSyncService` | Syncs settings with server defaults | + +--- + +## Data Flows + +### MODEL Mode (Single Model) + +See: [`docs/flows/data-flow-simplified-model-mode.md`](docs/flows/data-flow-simplified-model-mode.md) + +```mermaid +sequenceDiagram + participant User + participant UI + participant Stores + participant DB as IndexedDB + participant API as llama-server + + Note over User,API: Initialization + UI->>Stores: initialize() + Stores->>DB: load conversations + Stores->>API: GET /props + API-->>Stores: server config + Stores->>API: GET /v1/models + API-->>Stores: single model (auto-selected) + + Note over User,API: Chat Flow + User->>UI: send message + Stores->>DB: save user message + Stores->>API: POST /v1/chat/completions (stream) + loop streaming + API-->>Stores: SSE chunks + Stores-->>UI: reactive update + end + Stores->>DB: save assistant message +``` + +### ROUTER Mode (Multi-Model) + +See: [`docs/flows/data-flow-simplified-router-mode.md`](docs/flows/data-flow-simplified-router-mode.md) + +```mermaid +sequenceDiagram + participant User + participant UI + participant Stores + participant API as llama-server + + Note over User,API: Initialization + Stores->>API: GET /props + API-->>Stores: {role: "router"} + Stores->>API: GET /models + API-->>Stores: models[] with status + + Note over User,API: Model Selection + User->>UI: select model + alt model not loaded + Stores->>API: POST /models/load + loop poll status + Stores->>API: GET /models + end + Stores->>API: GET /props?model=X + end + Stores->>Stores: validate modalities + + Note over User,API: Chat Flow + Stores->>API: POST /v1/chat/completions {model: X} + loop streaming + API-->>Stores: SSE chunks + model info + end +``` + +### Detailed Flow Diagrams + +| Flow | Description | File | +| ------------- | ------------------------------------------ | ----------------------------------------------------------- | +| Chat | Message lifecycle, streaming, regeneration | [`chat-flow.md`](docs/flows/chat-flow.md) | +| Models | Loading, unloading, modality caching | [`models-flow.md`](docs/flows/models-flow.md) | +| Server | Props fetching, role detection | [`server-flow.md`](docs/flows/server-flow.md) | +| Conversations | CRUD, branching, import/export | [`conversations-flow.md`](docs/flows/conversations-flow.md) | +| Database | IndexedDB schema, operations | [`database-flow.md`](docs/flows/database-flow.md) | +| Settings | Parameter sync, user overrides | [`settings-flow.md`](docs/flows/settings-flow.md) | + +--- + +## Architectural Patterns + +### 1. Reactive State with Svelte 5 Runes + +All stores use Svelte 5's fine-grained reactivity: + +```typescript +// Store with reactive state +class ChatStore { + #isLoading = $state(false); + #currentResponse = $state(''); + + // Derived values auto-update + get isStreaming() { + return $derived(this.#isLoading && this.#currentResponse.length > 0); + } +} + +// Exported reactive accessors +export const isLoading = () => chatStore.isLoading; +export const currentResponse = () => chatStore.currentResponse; +``` + +### 2. Unidirectional Data Flow + +Data flows in one direction, making state predictable: + +```mermaid +flowchart LR + subgraph UI["UI Layer"] + A[User Action] --> B[Component] + end + + subgraph State["State Layer"] + B --> C[Store Method] + C --> D[State Update] + end + + subgraph IO["I/O Layer"] + C --> E[Service] + E --> F[API / IndexedDB] + F -.->|Response| D + end + + D -->|Reactive| B +``` + +Components dispatch actions to stores, stores coordinate with services for I/O, and state updates reactively propagate back to the UI. + +### 3. Per-Conversation State + +Enables concurrent streaming across multiple conversations: + +```typescript +class ChatStore { + chatLoadingStates = new Map(); + chatStreamingStates = new Map(); + abortControllers = new Map(); +} +``` + +### 4. Message Branching with Tree Structure + +Conversations are stored as a tree, not a linear list: + +```typescript +interface DatabaseMessage { + id: string; + parent: string | null; // Points to parent message + children: string[]; // List of child message IDs + // ... +} + +interface DatabaseConversation { + currentNode: string; // Currently viewed branch tip + // ... +} +``` + +Navigation between branches updates `currentNode` without losing history. + +### 5. Layered Service Architecture + +Stores handle state; services handle I/O: + +```text +┌─────────────────┐ +│ Stores │ Business logic, state management +├─────────────────┤ +│ Services │ API calls, database operations +├─────────────────┤ +│ Storage/API │ IndexedDB, LocalStorage, HTTP +└─────────────────┘ +``` + +### 6. Server Role Abstraction + +Single codebase handles both MODEL and ROUTER modes: + +```typescript +// serverStore.ts +get isRouterMode() { + return this.role === ServerRole.ROUTER; +} + +// Components conditionally render based on mode +{#if isRouterMode()} + +{/if} +``` + +### 7. Modality Validation + +Prevents sending attachments to incompatible models: + +```typescript +// useModelChangeValidation hook +const validate = (modelId: string) => { + const modelModalities = modelsStore.getModelModalities(modelId); + const conversationModalities = conversationsStore.usedModalities; + + // Check if model supports all used modalities + if (conversationModalities.hasImages && !modelModalities.vision) { + return { valid: false, reason: 'Model does not support images' }; + } + // ... +}; +``` + +### 8. Persistent Storage Strategy + +Data is persisted across sessions using two storage mechanisms: + +```mermaid +flowchart TB + subgraph Browser["Browser Storage"] + subgraph IDB["IndexedDB (Dexie)"] + C[Conversations] + M[Messages] + end + subgraph LS["LocalStorage"] + S[Settings Config] + O[User Overrides] + T[Theme Preference] + end + end + + subgraph Stores["Svelte Stores"] + CS[conversationsStore] --> C + CS --> M + SS[settingsStore] --> S + SS --> O + SS --> T + end +``` + +- **IndexedDB**: Conversations and messages (large, structured data) +- **LocalStorage**: Settings, user parameter overrides, theme (small key-value data) +- **Memory only**: Server props, model list (fetched fresh on each session) + +--- + +## Testing + +### Test Types + +| Type | Tool | Location | Command | +| ------------- | ------------------ | ---------------- | ------------------- | +| **Unit** | Vitest | `tests/unit/` | `npm run test:unit` | +| **UI/Visual** | Storybook + Vitest | `tests/stories/` | `npm run test:ui` | +| **E2E** | Playwright | `tests/e2e/` | `npm run test:e2e` | +| **Client** | Vitest | `tests/client/`. | `npm run test:unit` | + +### Running Tests + +```bash +# All tests +npm run test + +# Individual test suites +npm run test:e2e # End-to-end (requires llama-server) +npm run test:client # Client-side unit tests +npm run test:server # Server-side unit tests +npm run test:ui # Storybook visual tests +``` + +### Storybook Development + +```bash +npm run storybook # Start Storybook dev server on :6006 +npm run build-storybook # Build static Storybook +``` + +### Linting and Formatting + +```bash +npm run lint # Check code style +npm run format # Auto-format with Prettier +npm run check # TypeScript type checking +``` + +--- + +## Project Structure + +```text +tools/ui/ +├── src/ +│ ├── lib/ +│ │ ├── components/ # UI components (app/, ui/) +│ │ ├── hooks/ # Svelte hooks +│ │ ├── stores/ # State management +│ │ ├── services/ # API and database services +│ │ ├── types/ # TypeScript interfaces +│ │ └── utils/ # Utility functions +│ ├── routes/ # SvelteKit routes +│ └── styles/ # Global styles +├── static/ # Static assets +├── tests/ # Test files +├── docs/ # Architecture diagrams +│ ├── architecture/ # High-level architecture +│ └── flows/ # Feature-specific flows +└── .storybook/ # Storybook configuration +``` + +--- + +## Related Documentation + +- [llama.cpp Server README](../README.md) - Full server documentation +- [Multimodal Documentation](../../../docs/multimodal.md) - Image and audio support +- [Function Calling](../../../docs/function-calling.md) - Tool use capabilities diff --git a/tools/ui/components.json b/tools/ui/components.json new file mode 100644 index 000000000..224bd70ac --- /dev/null +++ b/tools/ui/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/app.css", + "baseColor": "neutral" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/components/ui/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" +} diff --git a/tools/ui/docs/architecture/high-level-architecture-simplified.md b/tools/ui/docs/architecture/high-level-architecture-simplified.md new file mode 100644 index 000000000..500f477c9 --- /dev/null +++ b/tools/ui/docs/architecture/high-level-architecture-simplified.md @@ -0,0 +1,145 @@ +```mermaid +flowchart TB + subgraph Routes["📍 Routes"] + R1["/ (Welcome)"] + R2["/chat/[id]"] + RL["+layout.svelte"] + end + + subgraph Components["🧩 Components"] + C_Sidebar["ChatSidebar"] + C_Screen["ChatScreen"] + C_Form["ChatForm"] + C_Messages["ChatMessages"] + C_Message["ChatMessage"] + C_ChatMessageAgenticContent["ChatMessageAgenticContent"] + C_MessageEditForm["ChatMessageEditForm"] + C_ModelsSelector["ModelsSelector"] + C_Settings["ChatSettings"] + C_McpSettings["McpServersSettings"] + C_McpResourceBrowser["McpResourceBrowser"] + C_McpServersSelector["McpServersSelector"] + end + + subgraph Hooks["🪝 Hooks"] + H1["useModelChangeValidation"] + H2["useProcessingState"] + end + + subgraph Stores["🗄️ Stores"] + S1["chatStore
          Chat interactions & streaming"] + SA["agenticStore
          Multi-turn agentic loop orchestration"] + S2["conversationsStore
          Conversation data, messages & MCP overrides"] + S3["modelsStore
          Model selection & loading"] + S4["serverStore
          Server props & role detection"] + S5["settingsStore
          User configuration incl. MCP"] + S6["mcpStore
          MCP servers, tools, prompts"] + S7["mcpResourceStore
          MCP resources & attachments"] + end + + subgraph Services["⚙️ Services"] + SV1["ChatService"] + SV2["ModelsService"] + SV3["PropsService"] + SV4["DatabaseService"] + SV5["ParameterSyncService"] + SV6["MCPService
          protocol operations"] + end + + subgraph Storage["💾 Storage"] + ST1["IndexedDB
          conversations, messages"] + ST2["LocalStorage
          config, userOverrides, mcpServers"] + end + + subgraph APIs["🌐 llama-server API"] + API1["/v1/chat/completions"] + API2["/props"] + API3["/models/*"] + API4["/v1/models"] + end + + subgraph ExternalMCP["🔌 External MCP Servers"] + EXT1["MCP Server 1
          WebSocket/HTTP/SSE"] + EXT2["MCP Server N"] + end + + %% Routes → Components + R1 & R2 --> C_Screen + RL --> C_Sidebar + + %% Layout runs MCP health checks + RL --> S6 + + %% Component hierarchy + C_Screen --> C_Form & C_Messages & C_Settings + C_Messages --> C_Message + C_Message --> C_ChatMessageAgenticContent + C_Message --> C_MessageEditForm + C_Form & C_MessageEditForm --> C_ModelsSelector + C_Form --> C_McpServersSelector + C_Settings --> C_McpSettings + C_McpSettings --> C_McpResourceBrowser + + %% Components → Hooks → Stores + C_Form & C_Messages --> H1 & H2 + H1 --> S3 & S4 + H2 --> S1 & S5 + + %% Components → Stores + C_Screen --> S1 & S2 + C_Sidebar --> S2 + C_ModelsSelector --> S3 & S4 + C_Settings --> S5 + C_McpSettings --> S6 + C_McpResourceBrowser --> S6 & S7 + C_McpServersSelector --> S6 + C_Form --> S6 + + %% chatStore → agenticStore → mcpStore (agentic loop) + S1 --> SA + SA --> SV1 + SA --> S6 + + %% Stores → Services + S1 --> SV1 & SV4 + S2 --> SV4 + S3 --> SV2 & SV3 + S4 --> SV3 + S5 --> SV5 + S6 --> SV6 + S7 --> SV6 + + %% Services → Storage + SV4 --> ST1 + SV5 --> ST2 + + %% Services → APIs + SV1 --> API1 + SV2 --> API3 & API4 + SV3 --> API2 + + %% MCP → External Servers + SV6 --> EXT1 & EXT2 + + %% Styling + classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px + classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px + classDef hookStyle fill:#fff8e1,stroke:#ff8f00,stroke-width:2px + classDef storeStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px + classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px + classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px + classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px + classDef mcpStyle fill:#e0f2f1,stroke:#00695c,stroke-width:2px + classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px + classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5 + + class R1,R2,RL routeStyle + class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_ChatMessageAgenticContent,C_MessageEditForm,C_ModelsSelector,C_Settings componentStyle + class C_McpSettings,C_McpResourceBrowser,C_McpServersSelector componentStyle + class H1,H2 hookStyle + class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle + class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle + class ST1,ST2 storageStyle + class API1,API2,API3,API4 apiStyle + class EXT1,EXT2 externalStyle +``` diff --git a/tools/ui/docs/architecture/high-level-architecture.md b/tools/ui/docs/architecture/high-level-architecture.md new file mode 100644 index 000000000..42ddb3f4f --- /dev/null +++ b/tools/ui/docs/architecture/high-level-architecture.md @@ -0,0 +1,373 @@ +```mermaid +flowchart TB +subgraph Routes["📍 Routes"] +R1["/ (+page.svelte)"] +R2["/chat/[id]"] +RL["+layout.svelte"] +end + + subgraph Components["🧩 Components"] + direction TB + subgraph LayoutComponents["Layout"] + C_Sidebar["ChatSidebar"] + C_Screen["ChatScreen"] + end + subgraph ChatUIComponents["Chat UI"] + C_Form["ChatForm"] + C_Messages["ChatMessages"] + C_Message["ChatMessage"] + C_MessageUser["ChatMessageUser"] + C_MessageEditForm["ChatMessageEditForm"] + C_Attach["ChatAttachments"] + C_ModelsSelector["ModelsSelector"] + C_Settings["ChatSettings"] + end + subgraph MCPComponents["MCP UI"] + C_McpSettings["McpServersSettings"] + C_McpServerCard["McpServerCard"] + C_McpResourceBrowser["McpResourceBrowser"] + C_McpResourcePreview["McpResourcePreview"] + C_McpServersSelector["McpServersSelector"] + end + end + + subgraph Hooks["🪝 Hooks"] + H1["useModelChangeValidation"] + H2["useProcessingState"] + H3["isMobile"] + end + + subgraph Stores["🗄️ Stores"] + direction TB + subgraph S1["chatStore"] + S1State["State:
          isLoading, currentResponse
          errorDialogState
          activeProcessingState
          chatLoadingStates
          chatStreamingStates
          abortControllers
          processingStates
          activeConversationId
          isStreamingActive"] + S1LoadState["Loading State:
          setChatLoading()
          isChatLoading()
          syncLoadingStateForChat()
          clearUIState()
          isChatLoadingPublic()
          getAllLoadingChats()
          getAllStreamingChats()"] + S1ProcState["Processing State:
          setActiveProcessingConversation()
          getProcessingState()
          clearProcessingState()
          getActiveProcessingState()
          updateProcessingStateFromTimings()
          getCurrentProcessingStateSync()
          restoreProcessingStateFromMessages()"] + S1Stream["Streaming:
          streamChatCompletion()
          startStreaming()
          stopStreaming()
          stopGeneration()
          isStreaming()"] + S1Error["Error Handling:
          showErrorDialog()
          dismissErrorDialog()
          isAbortError()"] + S1Msg["Message Operations:
          addMessage()
          sendMessage()
          updateMessage()
          deleteMessage()
          getDeletionInfo()"] + S1Regen["Regeneration:
          regenerateMessage()
          regenerateMessageWithBranching()
          continueAssistantMessage()"] + S1Edit["Editing:
          editAssistantMessage()
          editUserMessagePreserveResponses()
          editMessageWithBranching()
          clearEditMode()
          isEditModeActive()
          getAddFilesHandler()
          setEditModeActive()"] + S1Utils["Utilities:
          getApiOptions()
          parseTimingData()
          getOrCreateAbortController()
          getConversationModel()"] + end + subgraph SA["agenticStore"] + SAState["State:
          sessions (Map)
          isAnyRunning"] + SASession["Session Management:
          getSession()
          updateSession()
          clearSession()
          getActiveSessions()
          isRunning()
          currentTurn()
          totalToolCalls()
          lastError()
          streamingToolCall()"] + SAConfig["Configuration:
          getConfig()
          maxTurns, maxToolPreviewLines"] + SAFlow["Agentic Loop:
          runAgenticFlow()
          executeAgenticLoop()
          normalizeToolCalls()
          emitToolCallResult()
          extractBase64Attachments()"] + end + subgraph S2["conversationsStore"] + S2State["State:
          conversations
          activeConversation
          activeMessages
          isInitialized
          pendingMcpServerOverrides
          titleUpdateConfirmationCallback"] + S2Lifecycle["Lifecycle:
          initialize()
          loadConversations()
          clearActiveConversation()"] + S2ConvCRUD["Conversation CRUD:
          createConversation()
          loadConversation()
          deleteConversation()
          deleteAll()
          updateConversationName()
          updateConversationTitleWithConfirmation()"] + S2MsgMgmt["Message Management:
          refreshActiveMessages()
          addMessageToActive()
          updateMessageAtIndex()
          findMessageIndex()
          sliceActiveMessages()
          removeMessageAtIndex()
          getConversationMessages()"] + S2Nav["Navigation:
          navigateToSibling()
          updateCurrentNode()
          updateConversationTimestamp()"] + S2McpOverrides["MCP Per-Chat Overrides:
          getMcpServerOverride()
          getAllMcpServerOverrides()
          setMcpServerOverride()
          toggleMcpServerForChat()
          removeMcpServerOverride()
          isMcpServerEnabledForChat()
          clearPendingMcpServerOverrides()"] + S2Export["Import/Export:
          downloadConversation()
          exportAllConversations()
          importConversations()
          importConversationsData()
          triggerDownload()"] + S2Utils["Utilities:
          setTitleUpdateConfirmationCallback()"] + end + subgraph S3["modelsStore"] + S3State["State:
          models, routerModels
          selectedModelId
          selectedModelName
          loading, updating, error
          modelLoadingStates
          modelPropsCache
          modelPropsFetching
          propsCacheVersion"] + S3Getters["Computed Getters:
          selectedModel
          loadedModelIds
          loadingModelIds
          singleModelName"] + S3Modal["Modalities:
          getModelModalities()
          modelSupportsVision()
          modelSupportsAudio()
          getModelModalitiesArray()
          getModelProps()
          updateModelModalities()"] + S3Status["Status Queries:
          isModelLoaded()
          isModelOperationInProgress()
          getModelStatus()
          isModelPropsFetching()"] + S3Fetch["Data Fetching:
          fetch()
          fetchRouterModels()
          fetchModelProps()
          fetchModalitiesForLoadedModels()"] + S3Select["Model Selection:
          selectModelById()
          selectModelByName()
          clearSelection()
          findModelByName()
          findModelById()
          hasModel()"] + S3LoadUnload["Loading/Unloading Models:
          loadModel()
          unloadModel()
          ensureModelLoaded()
          waitForModelStatus()
          pollForModelStatus()"] + S3Utils["Utilities:
          toDisplayName()
          clear()"] + end + subgraph S4["serverStore"] + S4State["State:
          props
          loading, error
          role
          fetchPromise"] + S4Getters["Getters:
          defaultParams
          contextSize
          isRouterMode
          isModelMode"] + S4Data["Data Handling:
          fetch()
          getErrorMessage()
          clear()"] + S4Utils["Utilities:
          detectRole()"] + end + subgraph S5["settingsStore"] + S5State["State:
          config
          theme
          isInitialized
          userOverrides"] + S5Lifecycle["Lifecycle:
          initialize()
          loadConfig()
          saveConfig()
          loadTheme()
          saveTheme()"] + S5Update["Config Updates:
          updateConfig()
          updateMultipleConfig()
          updateTheme()"] + S5Reset["Reset:
          resetConfig()
          resetTheme()
          resetAll()
          resetParameterToServerDefault()"] + S5Sync["Server Sync:
          syncWithServerDefaults()
          forceSyncWithServerDefaults()"] + S5Utils["Utilities:
          getConfig()
          getAllConfig()
          getParameterInfo()
          getParameterDiff()
          getServerDefaults()
          clearAllUserOverrides()"] + end + subgraph S6["mcpStore"] + S6State["State:
          isInitializing, error
          toolCount, connectedServers
          healthChecks (Map)
          connections (Map)
          toolsIndex (Map)"] + S6Lifecycle["Lifecycle:
          ensureInitialized()
          initialize()
          shutdown()
          acquireConnection()
          releaseConnection()"] + S6Health["Health Checks:
          runHealthCheck()
          runHealthChecksForServers()
          updateHealthCheck()
          getHealthCheckState()
          clearHealthCheck()"] + S6Servers["Server Management:
          getServers()
          addServer()
          updateServer()
          removeServer()
          getServerById()
          getServerDisplayName()"] + S6Tools["Tool Operations:
          getToolDefinitionsForLLM()
          getToolNames()
          hasTool()
          getToolServer()
          executeTool()
          executeToolByName()"] + S6Prompts["Prompt Operations:
          getAllPrompts()
          getPrompt()
          hasPromptsCapability()
          getPromptCompletions()"] + end + subgraph S7["mcpResourceStore"] + S7State["State:
          serverResources (Map)
          cachedResources (Map)
          subscriptions (Map)
          attachments[]
          isLoading"] + S7Resources["Resource Discovery:
          setServerResources()
          getServerResources()
          getAllResourceInfos()
          getAllTemplateInfos()
          clearServerResources()"] + S7Cache["Caching:
          cacheResourceContent()
          getCachedContent()
          invalidateCache()
          clearCache()"] + S7Subs["Subscriptions:
          addSubscription()
          removeSubscription()
          isSubscribed()
          handleResourceUpdate()"] + S7Attach["Attachments:
          addAttachment()
          updateAttachmentContent()
          removeAttachment()
          clearAttachments()
          toMessageExtras()"] + end + + subgraph ReactiveExports["⚡ Reactive Exports"] + direction LR + subgraph ChatExports["chatStore"] + RE1["isLoading()"] + RE2["currentResponse()"] + RE3["errorDialog()"] + RE4["activeProcessingState()"] + RE5["isChatStreaming()"] + RE6["isChatLoading()"] + RE7["getChatStreaming()"] + RE8["getAllLoadingChats()"] + RE9["getAllStreamingChats()"] + RE9a["isEditModeActive()"] + RE9b["getAddFilesHandler()"] + RE9c["setEditModeActive()"] + RE9d["clearEditMode()"] + end + subgraph AgenticExports["agenticStore"] + REA1["agenticIsRunning()"] + REA2["agenticCurrentTurn()"] + REA3["agenticTotalToolCalls()"] + REA4["agenticLastError()"] + REA5["agenticStreamingToolCall()"] + REA6["agenticIsAnyRunning()"] + end + subgraph ConvExports["conversationsStore"] + RE10["conversations()"] + RE11["activeConversation()"] + RE12["activeMessages()"] + RE13["isConversationsInitialized()"] + end + subgraph ModelsExports["modelsStore"] + RE15["modelOptions()"] + RE16["routerModels()"] + RE17["modelsLoading()"] + RE18["modelsUpdating()"] + RE19["modelsError()"] + RE20["selectedModelId()"] + RE21["selectedModelName()"] + RE22["selectedModelOption()"] + RE23["loadedModelIds()"] + RE24["loadingModelIds()"] + RE25["propsCacheVersion()"] + RE26["singleModelName()"] + end + subgraph ServerExports["serverStore"] + RE27["serverProps()"] + RE28["serverLoading()"] + RE29["serverError()"] + RE30["serverRole()"] + RE31["defaultParams()"] + RE32["contextSize()"] + RE33["isRouterMode()"] + RE34["isModelMode()"] + end + subgraph SettingsExports["settingsStore"] + RE35["config()"] + RE36["theme()"] + RE37["isInitialized()"] + end + subgraph MCPExports["mcpStore / mcpResourceStore"] + RE38["mcpResources()"] + RE39["mcpResourceAttachments()"] + RE40["mcpHasResourceAttachments()"] + RE41["mcpTotalResourceCount()"] + RE42["mcpResourcesLoading()"] + end + end + end + + subgraph Services["⚙️ Services"] + direction TB + subgraph SV1["ChatService"] + SV1Msg["Messaging:
          sendMessage()"] + SV1Stream["Streaming:
          handleStreamResponse()
          handleNonStreamResponse()"] + SV1Convert["Conversion:
          convertDbMessageToApiChatMessageData()
          mergeToolCallDeltas()"] + SV1Utils["Utilities:
          stripReasoningContent()
          extractModelName()
          parseErrorResponse()"] + end + subgraph SV2["ModelsService"] + SV2List["Listing:
          list()
          listRouter()"] + SV2LoadUnload["Load/Unload:
          load()
          unload()"] + SV2Status["Status:
          isModelLoaded()
          isModelLoading()"] + end + subgraph SV3["PropsService"] + SV3Fetch["Fetching:
          fetch()
          fetchForModel()"] + end + subgraph SV4["DatabaseService"] + SV4Conv["Conversations:
          createConversation()
          getConversation()
          getAllConversations()
          updateConversation()
          deleteConversation()"] + SV4Msg["Messages:
          createMessageBranch()
          createRootMessage()
          createSystemMessage()
          getConversationMessages()
          updateMessage()
          deleteMessage()
          deleteMessageCascading()"] + SV4Node["Navigation:
          updateCurrentNode()"] + SV4Import["Import:
          importConversations()"] + end + subgraph SV5["ParameterSyncService"] + SV5Extract["Extraction:
          extractServerDefaults()"] + SV5Merge["Merging:
          mergeWithServerDefaults()"] + SV5Info["Info:
          getParameterInfo()
          canSyncParameter()
          getSyncableParameterKeys()
          validateServerParameter()"] + SV5Diff["Diff:
          createParameterDiff()"] + end + subgraph SV6["MCPService"] + SV6Transport["Transport:
          createTransport()
          WebSocket / StreamableHTTP / SSE"] + SV6Conn["Connection:
          connect()
          disconnect()"] + SV6Tools["Tools:
          listTools()
          callTool()"] + SV6Prompts["Prompts:
          listPrompts()
          getPrompt()"] + SV6Resources["Resources:
          listResources()
          listResourceTemplates()
          readResource()
          subscribeResource()
          unsubscribeResource()"] + SV6Complete["Completions:
          complete()"] + end + end + + subgraph ExternalMCP["🔌 External MCP Servers"] + EXT1["MCP Server 1
          (WebSocket/StreamableHTTP/SSE)"] + EXT2["MCP Server N"] + end + + subgraph Storage["💾 Storage"] + ST1["IndexedDB"] + ST2["conversations"] + ST3["messages"] + ST5["LocalStorage"] + ST6["config"] + ST7["userOverrides"] + ST8["mcpServers"] + end + + subgraph APIs["🌐 llama-server API"] + API1["/v1/chat/completions"] + API2["/props
          /props?model="] + API3["/models
          /models/load
          /models/unload"] + API4["/v1/models"] + end + + %% Routes render Components + R1 --> C_Screen + R2 --> C_Screen + RL --> C_Sidebar + + %% Layout runs MCP health checks on startup + RL --> S6 + + %% Component hierarchy + C_Screen --> C_Form & C_Messages & C_Settings + C_Messages --> C_Message + C_Message --> C_MessageUser + C_MessageUser --> C_MessageEditForm + C_MessageEditForm --> C_ModelsSelector + C_MessageEditForm --> C_Attach + C_Form --> C_ModelsSelector + C_Form --> C_Attach + C_Form --> C_McpServersSelector + C_Message --> C_Attach + + %% MCP Components hierarchy + C_Settings --> C_McpSettings + C_McpSettings --> C_McpServerCard + C_McpServerCard --> C_McpResourceBrowser + C_McpResourceBrowser --> C_McpResourcePreview + + %% Components use Hooks + C_Form --> H1 + C_Message --> H1 & H2 + C_MessageEditForm --> H1 + C_Screen --> H2 + + %% Hooks use Stores + H1 --> S3 & S4 + H2 --> S1 & S5 + + %% Components use Stores + C_Screen --> S1 & S2 + C_Messages --> S2 + C_Message --> S1 & S2 & S3 + C_Form --> S1 & S3 & S6 + C_Sidebar --> S2 + C_ModelsSelector --> S3 & S4 + C_Settings --> S5 + C_McpSettings --> S6 + C_McpServerCard --> S6 + C_McpResourceBrowser --> S6 & S7 + C_McpServersSelector --> S6 + + %% Stores export Reactive State + S1 -. exports .-> ChatExports + SA -. exports .-> AgenticExports + S2 -. exports .-> ConvExports + S3 -. exports .-> ModelsExports + S4 -. exports .-> ServerExports + S5 -. exports .-> SettingsExports + S6 -. exports .-> MCPExports + S7 -. exports .-> MCPExports + + %% chatStore → agenticStore (agentic loop orchestration) + S1 --> SA + SA --> SV1 + SA --> S6 + + %% Stores use Services + S1 --> SV1 & SV4 + S2 --> SV4 + S3 --> SV2 & SV3 + S4 --> SV3 + S5 --> SV5 + S6 --> SV6 + S7 --> SV6 + + %% Services to Storage + SV4 --> ST1 + ST1 --> ST2 & ST3 + SV5 --> ST5 + ST5 --> ST6 & ST7 & ST8 + + %% Services to APIs + SV1 --> API1 + SV2 --> API3 & API4 + SV3 --> API2 + + %% MCP → External Servers + SV6 --> EXT1 & EXT2 + + %% Styling + classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px + classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px + classDef componentGroupStyle fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px + classDef hookStyle fill:#fff8e1,stroke:#ff8f00,stroke-width:2px + classDef storeStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px + classDef stateStyle fill:#ffe0b2,stroke:#e65100,stroke-width:1px + classDef methodStyle fill:#ffecb3,stroke:#e65100,stroke-width:1px + classDef reactiveStyle fill:#fffde7,stroke:#f9a825,stroke-width:1px + classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px + classDef serviceMStyle fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px + classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5 + classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px + classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px + + class R1,R2,RL routeStyle + class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageUser,C_MessageEditForm componentStyle + class C_ModelsSelector,C_Settings componentStyle + class C_Attach componentStyle + class C_McpSettings,C_McpServerCard,C_McpResourceBrowser,C_McpResourcePreview,C_McpServersSelector componentStyle + class H1,H2,H3 hookStyle + class LayoutComponents,ChatUIComponents,MCPComponents componentGroupStyle + class Hooks hookStyle + classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px + classDef agenticMethodStyle fill:#c5cae9,stroke:#283593,stroke-width:1px + + class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle + class S1State,S2State,S3State,S4State,S5State,SAState,S6State,S7State stateStyle + class S1Msg,S1Regen,S1Edit,S1Stream,S1LoadState,S1ProcState,S1Error,S1Utils methodStyle + class SASession,SAConfig,SAFlow methodStyle + class S2Lifecycle,S2ConvCRUD,S2MsgMgmt,S2Nav,S2McpOverrides,S2Export,S2Utils methodStyle + class S3Getters,S3Modal,S3Status,S3Fetch,S3Select,S3LoadUnload,S3Utils methodStyle + class S4Getters,S4Data,S4Utils methodStyle + class S5Lifecycle,S5Update,S5Reset,S5Sync,S5Utils methodStyle + class S6Lifecycle,S6Health,S6Servers,S6Tools,S6Prompts methodStyle + class S7Resources,S7Cache,S7Subs,S7Attach methodStyle + class ChatExports,AgenticExports,ConvExports,ModelsExports,ServerExports,SettingsExports,MCPExports reactiveStyle + class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle + class SV6Transport,SV6Conn,SV6Tools,SV6Prompts,SV6Resources,SV6Complete serviceMStyle + class EXT1,EXT2 externalStyle + class SV1Msg,SV1Stream,SV1Convert,SV1Utils serviceMStyle + class SV2List,SV2LoadUnload,SV2Status serviceMStyle + class SV3Fetch serviceMStyle + class SV4Conv,SV4Msg,SV4Node,SV4Import serviceMStyle + class SV5Extract,SV5Merge,SV5Info,SV5Diff serviceMStyle + class ST1,ST2,ST3,ST5,ST6,ST7,ST8 storageStyle + class API1,API2,API3,API4 apiStyle +``` diff --git a/tools/ui/docs/flows/chat-flow.md b/tools/ui/docs/flows/chat-flow.md new file mode 100644 index 000000000..296693c6a --- /dev/null +++ b/tools/ui/docs/flows/chat-flow.md @@ -0,0 +1,228 @@ +```mermaid +sequenceDiagram + participant UI as 🧩 ChatForm / ChatMessage + participant chatStore as 🗄️ chatStore + participant agenticStore as 🗄️ agenticStore + participant convStore as 🗄️ conversationsStore + participant settingsStore as 🗄️ settingsStore + participant mcpStore as 🗄️ mcpStore + participant ChatSvc as ⚙️ ChatService + participant DbSvc as ⚙️ DatabaseService + participant API as 🌐 /v1/chat/completions + + Note over chatStore: State:
          isLoading, currentResponse
          errorDialogState, activeProcessingState
          chatLoadingStates (Map)
          chatStreamingStates (Map)
          abortControllers (Map)
          processingStates (Map) + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: 💬 SEND MESSAGE + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>chatStore: sendMessage(content, extras) + activate chatStore + + chatStore->>chatStore: setChatLoading(convId, true) + chatStore->>chatStore: clearChatStreaming(convId) + + alt no active conversation + chatStore->>convStore: createConversation() + Note over convStore: → see conversations-flow.mmd + end + + chatStore->>mcpStore: consumeResourceAttachmentsAsExtras() + Note right of mcpStore: Converts pending MCP resource
          attachments into message extras + + chatStore->>chatStore: addMessage("user", content, extras) + chatStore->>DbSvc: createMessageBranch(userMsg, parentId) + chatStore->>convStore: addMessageToActive(userMsg) + chatStore->>convStore: updateCurrentNode(userMsg.id) + + chatStore->>chatStore: createAssistantMessage(userMsg.id) + chatStore->>DbSvc: createMessageBranch(assistantMsg, userMsg.id) + chatStore->>convStore: addMessageToActive(assistantMsg) + + chatStore->>chatStore: streamChatCompletion(messages, assistantMsg) + deactivate chatStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: 🌊 STREAMING (with agentic flow detection) + %% ═══════════════════════════════════════════════════════════════════════════ + + activate chatStore + chatStore->>chatStore: startStreaming() + Note right of chatStore: isStreamingActive = true + + chatStore->>chatStore: setActiveProcessingConversation(convId) + chatStore->>chatStore: getOrCreateAbortController(convId) + Note right of chatStore: abortControllers.set(convId, new AbortController()) + + chatStore->>chatStore: getApiOptions() + Note right of chatStore: Merge from settingsStore.config:
          temperature, max_tokens, top_p, etc. + + alt agenticConfig.enabled && mcpStore has connected servers + chatStore->>agenticStore: runAgenticFlow(convId, messages, assistantMsg, options, signal) + Note over agenticStore: Multi-turn agentic loop:
          1. Call ChatService.sendMessage()
          2. If response has tool_calls → execute via mcpStore
          3. Append tool results as messages
          4. Loop until no more tool_calls or maxTurns
          → see agentic flow details below + agenticStore-->>chatStore: final response with timings + else standard (non-agentic) flow + chatStore->>ChatSvc: sendMessage(messages, options, signal) + end + + activate ChatSvc + + ChatSvc->>ChatSvc: convertDbMessageToApiChatMessageData(messages) + Note right of ChatSvc: DatabaseMessage[] → ApiChatMessageData[]
          Process attachments (images, PDFs, audio) + + ChatSvc->>API: POST /v1/chat/completions + Note right of API: {messages, model?, stream: true, ...params} + + loop SSE chunks + API-->>ChatSvc: data: {"choices":[{"delta":{...}}]} + ChatSvc->>ChatSvc: handleStreamResponse(response) + + alt content chunk + ChatSvc-->>chatStore: onChunk(content) + chatStore->>chatStore: setChatStreaming(convId, response, msgId) + Note right of chatStore: currentResponse = $state(accumulated) + chatStore->>convStore: updateMessageAtIndex(idx, {content}) + end + + alt reasoning chunk + ChatSvc-->>chatStore: onReasoningChunk(reasoning) + chatStore->>convStore: updateMessageAtIndex(idx, {thinking}) + end + + alt tool_calls chunk + ChatSvc-->>chatStore: onToolCallChunk(toolCalls) + chatStore->>convStore: updateMessageAtIndex(idx, {toolCalls}) + end + + alt model info + ChatSvc-->>chatStore: onModel(modelName) + chatStore->>chatStore: recordModel(modelName) + chatStore->>DbSvc: updateMessage(msgId, {model}) + end + + alt timings (during stream) + ChatSvc-->>chatStore: onTimings(timings, promptProgress) + chatStore->>chatStore: updateProcessingStateFromTimings() + end + + chatStore-->>UI: reactive $state update + end + + API-->>ChatSvc: data: [DONE] + ChatSvc-->>chatStore: onComplete(content, reasoning, timings, toolCalls) + deactivate ChatSvc + + chatStore->>chatStore: stopStreaming() + chatStore->>DbSvc: updateMessage(msgId, {content, timings, model}) + chatStore->>convStore: updateCurrentNode(msgId) + chatStore->>chatStore: setChatLoading(convId, false) + chatStore->>chatStore: clearChatStreaming(convId) + chatStore->>chatStore: clearProcessingState(convId) + deactivate chatStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: ⏹️ STOP GENERATION + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>chatStore: stopGeneration() + activate chatStore + chatStore->>chatStore: savePartialResponseIfNeeded(convId) + Note right of chatStore: Save currentResponse to DB if non-empty + chatStore->>chatStore: abortControllers.get(convId).abort() + Note right of chatStore: fetch throws AbortError → caught by isAbortError() + chatStore->>chatStore: stopStreaming() + chatStore->>chatStore: setChatLoading(convId, false) + chatStore->>chatStore: clearChatStreaming(convId) + chatStore->>chatStore: clearProcessingState(convId) + deactivate chatStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: 🔁 REGENERATE + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>chatStore: regenerateMessageWithBranching(msgId, model?) + activate chatStore + chatStore->>convStore: findMessageIndex(msgId) + chatStore->>chatStore: Get parent of target message + chatStore->>chatStore: createAssistantMessage(parentId) + chatStore->>DbSvc: createMessageBranch(newAssistantMsg, parentId) + chatStore->>convStore: refreshActiveMessages() + Note right of chatStore: Same streaming flow + chatStore->>chatStore: streamChatCompletion(...) + deactivate chatStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: ➡️ CONTINUE + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>chatStore: continueAssistantMessage(msgId) + activate chatStore + chatStore->>chatStore: Get existing content from message + chatStore->>chatStore: streamChatCompletion(..., existingContent) + Note right of chatStore: Appends to existing message content + deactivate chatStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: ✏️ EDIT USER MESSAGE + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>chatStore: editMessageWithBranching(msgId, newContent, extras) + activate chatStore + chatStore->>chatStore: Get parent of target message + chatStore->>DbSvc: createMessageBranch(editedMsg, parentId) + chatStore->>convStore: refreshActiveMessages() + Note right of chatStore: Creates new branch, original preserved + chatStore->>chatStore: createAssistantMessage(editedMsg.id) + chatStore->>chatStore: streamChatCompletion(...) + Note right of chatStore: Automatically regenerates response + deactivate chatStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: ❌ ERROR HANDLING + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over chatStore: On stream error (non-abort): + chatStore->>chatStore: showErrorDialog(type, message) + Note right of chatStore: errorDialogState = {type: 'timeout'|'server', message} + chatStore->>convStore: removeMessageAtIndex(failedMsgIdx) + chatStore->>DbSvc: deleteMessage(failedMsgId) + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: 🤖 AGENTIC LOOP (when agenticConfig.enabled) + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over agenticStore: agenticStore.runAgenticFlow(convId, messages, assistantMsg, options, signal) + activate agenticStore + agenticStore->>agenticStore: getSession(convId) or create new + agenticStore->>agenticStore: updateSession(turn: 0, running: true) + + loop executeAgenticLoop (until no tool_calls or maxTurns) + agenticStore->>agenticStore: turn++ + agenticStore->>ChatSvc: sendMessage(messages, options, signal) + ChatSvc->>API: POST /v1/chat/completions + API-->>ChatSvc: response with potential tool_calls + ChatSvc-->>agenticStore: onComplete(content, reasoning, timings, toolCalls) + + alt response has tool_calls + agenticStore->>agenticStore: normalizeToolCalls(toolCalls) + loop for each tool_call + agenticStore->>agenticStore: updateSession(streamingToolCall) + agenticStore->>mcpStore: executeTool(mcpCall, signal) + mcpStore-->>agenticStore: tool result + agenticStore->>agenticStore: extractBase64Attachments(result) + agenticStore->>agenticStore: emitToolCallResult(convId, ...) + agenticStore->>convStore: addMessageToActive(toolResultMsg) + agenticStore->>DbSvc: createMessageBranch(toolResultMsg) + end + agenticStore->>agenticStore: Create new assistantMsg for next turn + Note right of agenticStore: Continue loop with updated messages + else no tool_calls (final response) + agenticStore->>agenticStore: buildFinalTimings(allTurns) + Note right of agenticStore: Break loop, return final response + end + end + + agenticStore->>agenticStore: updateSession(running: false) + agenticStore-->>chatStore: final content, timings, model + deactivate agenticStore +``` diff --git a/tools/ui/docs/flows/conversations-flow.md b/tools/ui/docs/flows/conversations-flow.md new file mode 100644 index 000000000..bd2309bc0 --- /dev/null +++ b/tools/ui/docs/flows/conversations-flow.md @@ -0,0 +1,183 @@ +```mermaid +sequenceDiagram + participant UI as 🧩 ChatSidebar / ChatScreen + participant convStore as 🗄️ conversationsStore + participant chatStore as 🗄️ chatStore + participant DbSvc as ⚙️ DatabaseService + participant IDB as 💾 IndexedDB + + Note over convStore: State:
          conversations: DatabaseConversation[]
          activeConversation: DatabaseConversation | null
          activeMessages: DatabaseMessage[]
          isInitialized: boolean
          pendingMcpServerOverrides: Map<string, McpServerOverride> + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,IDB: 🚀 INITIALIZATION + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over convStore: Auto-initialized in constructor (browser only) + convStore->>convStore: initialize() + activate convStore + convStore->>convStore: loadConversations() + convStore->>DbSvc: getAllConversations() + DbSvc->>IDB: SELECT * FROM conversations ORDER BY lastModified DESC + IDB-->>DbSvc: Conversation[] + DbSvc-->>convStore: conversations + convStore->>convStore: conversations = $state(data) + convStore->>convStore: isInitialized = true + deactivate convStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,IDB: ➕ CREATE CONVERSATION + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>convStore: createConversation(name?) + activate convStore + convStore->>DbSvc: createConversation(name || "New Chat") + DbSvc->>IDB: INSERT INTO conversations + IDB-->>DbSvc: conversation {id, name, lastModified, currNode: ""} + DbSvc-->>convStore: conversation + convStore->>convStore: conversations.unshift(conversation) + convStore->>convStore: activeConversation = $state(conversation) + convStore->>convStore: activeMessages = $state([]) + + alt pendingMcpServerOverrides has entries + loop each pending override + convStore->>DbSvc: Store MCP server override for new conversation + end + convStore->>convStore: clearPendingMcpServerOverrides() + end + deactivate convStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,IDB: 📂 LOAD CONVERSATION + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>convStore: loadConversation(convId) + activate convStore + convStore->>DbSvc: getConversation(convId) + DbSvc->>IDB: SELECT * FROM conversations WHERE id = ? + IDB-->>DbSvc: conversation + convStore->>convStore: activeConversation = $state(conversation) + + convStore->>convStore: refreshActiveMessages() + convStore->>DbSvc: getConversationMessages(convId) + DbSvc->>IDB: SELECT * FROM messages WHERE convId = ? + IDB-->>DbSvc: allMessages[] + convStore->>convStore: filterByLeafNodeId(allMessages, currNode) + Note right of convStore: Filter to show only current branch path + convStore->>convStore: activeMessages = $state(filtered) + + Note right of convStore: Route (+page.svelte) then calls:
          chatStore.syncLoadingStateForChat(convId) + deactivate convStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,IDB: 🌳 MESSAGE BRANCHING MODEL + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over IDB: Message Tree Structure:
          - Each message has parent (null for root)
          - Each message has children[] array
          - Conversation.currNode points to active leaf
          - filterByLeafNodeId() traverses from root to currNode + + rect rgb(240, 240, 255) + Note over convStore: Example Branch Structure: + Note over convStore: root → user1 → assistant1 → user2 → assistant2a (currNode)
          ↘ assistant2b (alt branch) + end + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,IDB: ↔️ BRANCH NAVIGATION + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>convStore: navigateToSibling(msgId, direction) + activate convStore + convStore->>convStore: Find message in activeMessages + convStore->>convStore: Get parent message + convStore->>convStore: Find sibling in parent.children[] + convStore->>convStore: findLeafNode(siblingId, allMessages) + Note right of convStore: Navigate to leaf of sibling branch + convStore->>convStore: updateCurrentNode(leafId) + convStore->>DbSvc: updateCurrentNode(convId, leafId) + DbSvc->>IDB: UPDATE conversations SET currNode = ? + convStore->>convStore: refreshActiveMessages() + deactivate convStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,IDB: 📝 UPDATE CONVERSATION + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>convStore: updateConversationName(convId, newName) + activate convStore + convStore->>DbSvc: updateConversation(convId, {name: newName}) + DbSvc->>IDB: UPDATE conversations SET name = ? + convStore->>convStore: Update in conversations array + deactivate convStore + + Note over convStore: Auto-title update (after first response): + convStore->>convStore: updateConversationTitleWithConfirmation() + convStore->>convStore: titleUpdateConfirmationCallback?() + Note right of convStore: Shows dialog if title would change + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,IDB: 🗑️ DELETE CONVERSATION + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>convStore: deleteConversation(convId) + activate convStore + convStore->>DbSvc: deleteConversation(convId) + DbSvc->>IDB: DELETE FROM conversations WHERE id = ? + DbSvc->>IDB: DELETE FROM messages WHERE convId = ? + convStore->>convStore: conversations.filter(c => c.id !== convId) + alt deleted active conversation + convStore->>convStore: clearActiveConversation() + end + deactivate convStore + + UI->>convStore: deleteAll() + activate convStore + convStore->>DbSvc: Delete all conversations and messages + convStore->>convStore: conversations = [] + convStore->>convStore: clearActiveConversation() + deactivate convStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,IDB: � MCP SERVER PER-CHAT OVERRIDES + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over convStore: Conversations can override which MCP servers are enabled. + Note over convStore: Uses pendingMcpServerOverrides before conversation
          is created, then persists to conversation metadata. + + UI->>convStore: setMcpServerOverride(convId, serverName, override) + Note right of convStore: override = {enabled: boolean} + + UI->>convStore: toggleMcpServerForChat(convId, serverName, enabled) + activate convStore + convStore->>convStore: setMcpServerOverride(convId, serverName, {enabled}) + deactivate convStore + + UI->>convStore: isMcpServerEnabledForChat(convId, serverName) + Note right of convStore: Check override → fall back to global MCP config + + UI->>convStore: getAllMcpServerOverrides(convId) + Note right of convStore: Returns all overrides for a conversation + + UI->>convStore: removeMcpServerOverride(convId, serverName) + UI->>convStore: getMcpServerOverride(convId, serverName) + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,IDB: 📤 EXPORT / 📥 IMPORT + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>convStore: exportAllConversations() + activate convStore + convStore->>DbSvc: getAllConversations() + loop each conversation + convStore->>DbSvc: getConversationMessages(convId) + end + convStore->>convStore: triggerDownload(JSON blob) + deactivate convStore + + UI->>convStore: importConversations(file) + activate convStore + convStore->>convStore: Parse JSON file + convStore->>convStore: importConversationsData(parsed) + convStore->>DbSvc: importConversations(parsed) + Note right of DbSvc: Skips duplicate conversations
          (checks existing by ID) + DbSvc->>IDB: INSERT conversations + messages (skip existing) + convStore->>convStore: loadConversations() + deactivate convStore +``` diff --git a/tools/ui/docs/flows/data-flow-simplified-model-mode.md b/tools/ui/docs/flows/data-flow-simplified-model-mode.md new file mode 100644 index 000000000..07b362147 --- /dev/null +++ b/tools/ui/docs/flows/data-flow-simplified-model-mode.md @@ -0,0 +1,45 @@ +```mermaid +%% MODEL Mode Data Flow (single model) +%% Detailed flows: ./flows/server-flow.mmd, ./flows/models-flow.mmd, ./flows/chat-flow.mmd + +sequenceDiagram + participant User as 👤 User + participant UI as 🧩 UI + participant Stores as 🗄️ Stores + participant DB as 💾 IndexedDB + participant API as 🌐 llama-server + + Note over User,API: 🚀 Initialization (see: server-flow.mmd, models-flow.mmd) + + UI->>Stores: initialize() + Stores->>DB: load conversations + Stores->>API: GET /props + API-->>Stores: server config + modalities + Stores->>API: GET /v1/models + API-->>Stores: single model (auto-selected) + + Note over User,API: 💬 Chat Flow (see: chat-flow.mmd) + + User->>UI: send message + UI->>Stores: sendMessage() + Stores->>DB: save user message + Stores->>API: POST /v1/chat/completions (stream) + loop streaming + API-->>Stores: SSE chunks + Stores-->>UI: reactive update + end + API-->>Stores: done + timings + Stores->>DB: save assistant message + + Note over User,API: 🔁 Regenerate + + User->>UI: regenerate + Stores->>DB: create message branch + Note right of Stores: same streaming flow + + Note over User,API: ⏹️ Stop + + User->>UI: stop + Stores->>Stores: abort stream + Stores->>DB: save partial response +``` diff --git a/tools/ui/docs/flows/data-flow-simplified-router-mode.md b/tools/ui/docs/flows/data-flow-simplified-router-mode.md new file mode 100644 index 000000000..bccacf568 --- /dev/null +++ b/tools/ui/docs/flows/data-flow-simplified-router-mode.md @@ -0,0 +1,77 @@ +```mermaid +%% ROUTER Mode Data Flow (multi-model) +%% Detailed flows: ./flows/server-flow.mmd, ./flows/models-flow.mmd, ./flows/chat-flow.mmd + +sequenceDiagram + participant User as 👤 User + participant UI as 🧩 UI + participant Stores as 🗄️ Stores + participant DB as 💾 IndexedDB + participant API as 🌐 llama-server + + Note over User,API: 🚀 Initialization (see: server-flow.mmd, models-flow.mmd) + + UI->>Stores: initialize() + Stores->>DB: load conversations + Stores->>API: GET /props + API-->>Stores: {role: "router"} + Stores->>API: GET /v1/models + API-->>Stores: models[] with status (loaded/available) + loop each loaded model + Stores->>API: GET /props?model=X + API-->>Stores: modalities (vision/audio) + end + + Note over User,API: 🔄 Model Selection (see: models-flow.mmd) + + User->>UI: select model + alt model not loaded + Stores->>API: POST /models/load + loop poll status + Stores->>API: GET /v1/models + API-->>Stores: check if loaded + end + Stores->>API: GET /props?model=X + API-->>Stores: cache modalities + end + Stores->>Stores: validate modalities vs conversation + alt valid + Stores->>Stores: select model + else invalid + Stores->>API: POST /models/unload + UI->>User: show error toast + end + + Note over User,API: 💬 Chat Flow (see: chat-flow.mmd) + + User->>UI: send message + UI->>Stores: sendMessage() + Stores->>DB: save user message + Stores->>API: POST /v1/chat/completions {model: X} + Note right of API: router forwards to model + loop streaming + API-->>Stores: SSE chunks + model info + Stores-->>UI: reactive update + end + API-->>Stores: done + timings + Stores->>DB: save assistant message + model used + + Note over User,API: 🔁 Regenerate (optional: different model) + + User->>UI: regenerate + Stores->>Stores: validate modalities up to this message + Stores->>DB: create message branch + Note right of Stores: same streaming flow + + Note over User,API: ⏹️ Stop + + User->>UI: stop + Stores->>Stores: abort stream + Stores->>DB: save partial response + + Note over User,API: 🗑️ LRU Unloading + + Note right of API: Server auto-unloads LRU models
          when cache full + User->>UI: select unloaded model + Note right of Stores: triggers load flow again +``` diff --git a/tools/ui/docs/flows/database-flow.md b/tools/ui/docs/flows/database-flow.md new file mode 100644 index 000000000..38cd6941c --- /dev/null +++ b/tools/ui/docs/flows/database-flow.md @@ -0,0 +1,174 @@ +```mermaid +sequenceDiagram + participant Store as 🗄️ Stores + participant DbSvc as ⚙️ DatabaseService + participant Dexie as 📦 Dexie ORM + participant IDB as 💾 IndexedDB + + Note over DbSvc: Stateless service - all methods static
          Database: "LlamacppWebui" + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over Store,IDB: 📊 SCHEMA + %% ═══════════════════════════════════════════════════════════════════════════ + + rect rgb(240, 248, 255) + Note over IDB: conversations table:
          id (PK), lastModified, currNode, name + end + + rect rgb(255, 248, 240) + Note over IDB: messages table:
          id (PK), convId (FK), type, role, timestamp,
          parent, children[], content, thinking,
          toolCalls, extra[], model, timings + end + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over Store,IDB: 💬 CONVERSATIONS CRUD + %% ═══════════════════════════════════════════════════════════════════════════ + + Store->>DbSvc: createConversation(name) + activate DbSvc + DbSvc->>DbSvc: Generate UUID + DbSvc->>Dexie: db.conversations.add({id, name, lastModified, currNode: ""}) + Dexie->>IDB: INSERT + IDB-->>Dexie: success + DbSvc-->>Store: DatabaseConversation + deactivate DbSvc + + Store->>DbSvc: getConversation(convId) + DbSvc->>Dexie: db.conversations.get(convId) + Dexie->>IDB: SELECT WHERE id = ? + IDB-->>DbSvc: DatabaseConversation + + Store->>DbSvc: getAllConversations() + DbSvc->>Dexie: db.conversations.orderBy('lastModified').reverse().toArray() + Dexie->>IDB: SELECT ORDER BY lastModified DESC + IDB-->>DbSvc: DatabaseConversation[] + + Store->>DbSvc: updateConversation(convId, updates) + DbSvc->>Dexie: db.conversations.update(convId, {...updates, lastModified}) + Dexie->>IDB: UPDATE + + Store->>DbSvc: deleteConversation(convId) + activate DbSvc + DbSvc->>Dexie: db.conversations.delete(convId) + Dexie->>IDB: DELETE FROM conversations + DbSvc->>Dexie: db.messages.where('convId').equals(convId).delete() + Dexie->>IDB: DELETE FROM messages WHERE convId = ? + deactivate DbSvc + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over Store,IDB: 📝 MESSAGES CRUD + %% ═══════════════════════════════════════════════════════════════════════════ + + Store->>DbSvc: createRootMessage(convId) + activate DbSvc + DbSvc->>DbSvc: Create root message {type: "root", parent: null} + DbSvc->>Dexie: db.messages.add(rootMsg) + Dexie->>IDB: INSERT + DbSvc-->>Store: rootMessageId + deactivate DbSvc + + Store->>DbSvc: createSystemMessage(convId, content, parentId) + activate DbSvc + DbSvc->>DbSvc: Create message {role: "system", parent: parentId} + DbSvc->>Dexie: db.messages.add(systemMsg) + Dexie->>IDB: INSERT + DbSvc-->>Store: DatabaseMessage + deactivate DbSvc + + Store->>DbSvc: createMessageBranch(message, parentId) + activate DbSvc + DbSvc->>DbSvc: Generate UUID for new message + DbSvc->>Dexie: db.messages.add({...message, id, parent: parentId}) + Dexie->>IDB: INSERT message + + alt parentId exists + DbSvc->>Dexie: db.messages.get(parentId) + Dexie->>IDB: SELECT parent + DbSvc->>DbSvc: parent.children.push(newId) + DbSvc->>Dexie: db.messages.update(parentId, {children}) + Dexie->>IDB: UPDATE parent.children + end + + DbSvc->>Dexie: db.conversations.update(convId, {currNode: newId}) + Dexie->>IDB: UPDATE conversation.currNode + DbSvc-->>Store: DatabaseMessage + deactivate DbSvc + + Store->>DbSvc: getConversationMessages(convId) + DbSvc->>Dexie: db.messages.where('convId').equals(convId).toArray() + Dexie->>IDB: SELECT WHERE convId = ? + IDB-->>DbSvc: DatabaseMessage[] + + Store->>DbSvc: updateMessage(msgId, updates) + DbSvc->>Dexie: db.messages.update(msgId, updates) + Dexie->>IDB: UPDATE + + Store->>DbSvc: deleteMessage(msgId) + DbSvc->>Dexie: db.messages.delete(msgId) + Dexie->>IDB: DELETE + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over Store,IDB: 🌳 BRANCHING OPERATIONS + %% ═══════════════════════════════════════════════════════════════════════════ + + Store->>DbSvc: updateCurrentNode(convId, nodeId) + DbSvc->>Dexie: db.conversations.update(convId, {currNode: nodeId, lastModified}) + Dexie->>IDB: UPDATE + + Store->>DbSvc: deleteMessageCascading(msgId) + activate DbSvc + DbSvc->>DbSvc: findDescendantMessages(msgId, allMessages) + Note right of DbSvc: Recursively find all children + loop each descendant + DbSvc->>Dexie: db.messages.delete(descendantId) + Dexie->>IDB: DELETE + end + DbSvc->>Dexie: db.messages.delete(msgId) + Dexie->>IDB: DELETE target message + + alt target message has a parent + DbSvc->>Dexie: db.messages.get(parentId) + DbSvc->>DbSvc: parent.children.filter(id !== msgId) + DbSvc->>Dexie: db.messages.update(parentId, {children}) + Note right of DbSvc: Remove deleted message from parent's children[] + end + deactivate DbSvc + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over Store,IDB: 📥 IMPORT + %% ═══════════════════════════════════════════════════════════════════════════ + + Store->>DbSvc: importConversations(data) + activate DbSvc + loop each conversation in data + DbSvc->>Dexie: db.conversations.get(conv.id) + alt conversation already exists + Note right of DbSvc: Skip duplicate (keep existing) + else conversation is new + DbSvc->>Dexie: db.conversations.add(conversation) + Dexie->>IDB: INSERT conversation + loop each message + DbSvc->>Dexie: db.messages.add(message) + Dexie->>IDB: INSERT message + end + end + end + deactivate DbSvc + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over Store,IDB: 🔗 MESSAGE TREE UTILITIES + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over DbSvc: Used by stores (imported from utils): + + rect rgb(240, 255, 240) + Note over DbSvc: filterByLeafNodeId(messages, leafId)
          → Returns path from root to leaf
          → Used to display current branch + end + + rect rgb(240, 255, 240) + Note over DbSvc: findLeafNode(startId, messages)
          → Traverse to deepest child
          → Used for branch navigation + end + + rect rgb(240, 255, 240) + Note over DbSvc: findDescendantMessages(msgId, messages)
          → Find all children recursively
          → Used for cascading deletes + end +``` diff --git a/tools/ui/docs/flows/mcp-flow.md b/tools/ui/docs/flows/mcp-flow.md new file mode 100644 index 000000000..c8aa66659 --- /dev/null +++ b/tools/ui/docs/flows/mcp-flow.md @@ -0,0 +1,226 @@ +```mermaid +sequenceDiagram + participant UI as 🧩 McpServersSettings / ChatForm + participant chatStore as 🗄️ chatStore + participant mcpStore as 🗄️ mcpStore + participant mcpResStore as 🗄️ mcpResourceStore + participant convStore as 🗄️ conversationsStore + participant MCPSvc as ⚙️ MCPService + participant LS as 💾 LocalStorage + participant ExtMCP as 🔌 External MCP Server + + Note over mcpStore: State:
          isInitializing, error
          toolCount, connectedServers
          healthChecks (Map)
          connections (Map)
          toolsIndex (Map)
          serverConfigs (Map) + + Note over mcpResStore: State:
          serverResources (Map)
          cachedResources (Map)
          subscriptions (Map)
          attachments[] + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,ExtMCP: 🚀 INITIALIZATION (App Startup) + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>mcpStore: ensureInitialized() + activate mcpStore + + mcpStore->>LS: get(MCP_SERVERS_LOCALSTORAGE_KEY) + LS-->>mcpStore: MCPServerSettingsEntry[] + + mcpStore->>mcpStore: parseServerSettings(servers) + Note right of mcpStore: Filter enabled servers
          Build MCPServerConfig objects
          Per-chat overrides checked via convStore + + loop For each enabled server + mcpStore->>mcpStore: runHealthCheck(serverId) + mcpStore->>mcpStore: updateHealthCheck(id, CONNECTING) + + mcpStore->>MCPSvc: connect(serverName, config, clientInfo, capabilities, onPhase) + activate MCPSvc + + MCPSvc->>MCPSvc: createTransport(config) + Note right of MCPSvc: WebSocket / StreamableHTTP / SSE
          with optional CORS proxy + + MCPSvc->>ExtMCP: Transport handshake + ExtMCP-->>MCPSvc: Connection established + + MCPSvc->>ExtMCP: Initialize request + Note right of ExtMCP: Exchange capabilities
          Server info, protocol version + + ExtMCP-->>MCPSvc: InitializeResult (serverInfo, capabilities) + + MCPSvc->>ExtMCP: listTools() + ExtMCP-->>MCPSvc: Tool[] + + MCPSvc-->>mcpStore: MCPConnection + deactivate MCPSvc + + mcpStore->>mcpStore: connections.set(serverName, connection) + mcpStore->>mcpStore: indexTools(connection.tools, serverName) + Note right of mcpStore: toolsIndex.set(toolName, serverName)
          Handle name conflicts with prefixes + + mcpStore->>mcpStore: updateHealthCheck(id, SUCCESS) + mcpStore->>mcpStore: _connectedServers.push(serverName) + + alt Server supports resources + mcpStore->>MCPSvc: listAllResources(connection) + MCPSvc->>ExtMCP: listResources() + ExtMCP-->>MCPSvc: MCPResource[] + MCPSvc-->>mcpStore: resources + + mcpStore->>MCPSvc: listAllResourceTemplates(connection) + MCPSvc->>ExtMCP: listResourceTemplates() + ExtMCP-->>MCPSvc: MCPResourceTemplate[] + MCPSvc-->>mcpStore: templates + + mcpStore->>mcpResStore: setServerResources(serverName, resources, templates) + end + end + + mcpStore->>mcpStore: _isInitializing = false + deactivate mcpStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,ExtMCP: 🔧 TOOL EXECUTION (Chat with Tools) + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>mcpStore: executeTool(mcpCall: MCPToolCall, signal?) + activate mcpStore + + mcpStore->>mcpStore: toolsIndex.get(mcpCall.function.name) + Note right of mcpStore: Resolve serverName from toolsIndex
          MCPToolCall = {id, type, function: {name, arguments}} + + mcpStore->>mcpStore: acquireConnection() + Note right of mcpStore: activeFlowCount++
          Prevent shutdown during execution + + mcpStore->>mcpStore: connection = connections.get(serverName) + + mcpStore->>MCPSvc: callTool(connection, {name, arguments}, signal) + activate MCPSvc + + MCPSvc->>MCPSvc: throwIfAborted(signal) + MCPSvc->>ExtMCP: callTool(name, arguments) + + alt Tool execution success + ExtMCP-->>MCPSvc: ToolCallResult (content, isError) + MCPSvc->>MCPSvc: formatToolResult(result) + Note right of MCPSvc: Handle text, image (base64),
          embedded resource content + MCPSvc-->>mcpStore: ToolExecutionResult + else Tool execution error + ExtMCP-->>MCPSvc: Error + MCPSvc-->>mcpStore: throw Error + else Aborted + MCPSvc-->>mcpStore: throw AbortError + end + + deactivate MCPSvc + + mcpStore->>mcpStore: releaseConnection() + Note right of mcpStore: activeFlowCount-- + + mcpStore-->>UI: ToolExecutionResult + deactivate mcpStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,ExtMCP: � RESOURCE ATTACHMENT CONSUMPTION + %% ═══════════════════════════════════════════════════════════════════════════ + + chatStore->>mcpStore: consumeResourceAttachmentsAsExtras() + activate mcpStore + mcpStore->>mcpResStore: getAttachments() + mcpResStore-->>mcpStore: MCPResourceAttachment[] + mcpStore->>mcpStore: Convert attachments to message extras + mcpStore->>mcpResStore: clearAttachments() + mcpStore-->>chatStore: MessageExtra[] (for user message) + deactivate mcpStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,ExtMCP: �📝 PROMPT OPERATIONS + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>mcpStore: getAllPrompts() + activate mcpStore + + loop For each connected server with prompts capability + mcpStore->>MCPSvc: listPrompts(connection) + MCPSvc->>ExtMCP: listPrompts() + ExtMCP-->>MCPSvc: Prompt[] + MCPSvc-->>mcpStore: prompts + end + + mcpStore-->>UI: MCPPromptInfo[] (with serverName) + deactivate mcpStore + + UI->>mcpStore: getPrompt(serverName, promptName, args?) + activate mcpStore + + mcpStore->>MCPSvc: getPrompt(connection, name, args) + MCPSvc->>ExtMCP: getPrompt({name, arguments}) + ExtMCP-->>MCPSvc: GetPromptResult (messages) + MCPSvc-->>mcpStore: GetPromptResult + + mcpStore-->>UI: GetPromptResult + deactivate mcpStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,ExtMCP: 📁 RESOURCE OPERATIONS + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>mcpResStore: addAttachment(resourceInfo) + activate mcpResStore + mcpResStore->>mcpResStore: Create MCPResourceAttachment (loading: true) + mcpResStore-->>UI: attachment + + UI->>mcpStore: readResource(serverName, uri) + activate mcpStore + + mcpStore->>MCPSvc: readResource(connection, uri) + MCPSvc->>ExtMCP: readResource({uri}) + ExtMCP-->>MCPSvc: MCPReadResourceResult (contents) + MCPSvc-->>mcpStore: contents + + mcpStore-->>UI: MCPResourceContent[] + deactivate mcpStore + + UI->>mcpResStore: updateAttachmentContent(attachmentId, content) + mcpResStore->>mcpResStore: cacheResourceContent(resource, content) + deactivate mcpResStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,ExtMCP: 🔄 AUTO-RECONNECTION + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over mcpStore: On WebSocket close or connection error: + mcpStore->>mcpStore: autoReconnect(serverName, attempt) + activate mcpStore + + mcpStore->>mcpStore: Calculate backoff delay + Note right of mcpStore: delay = min(30s, 1s * 2^attempt) + + mcpStore->>mcpStore: Wait for delay + mcpStore->>mcpStore: reconnectServer(serverName) + + alt Reconnection success + mcpStore->>mcpStore: updateHealthCheck(id, SUCCESS) + else Max attempts reached + mcpStore->>mcpStore: updateHealthCheck(id, ERROR) + end + deactivate mcpStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,ExtMCP: 🛑 SHUTDOWN + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>mcpStore: shutdown() + activate mcpStore + + mcpStore->>mcpStore: Wait for activeFlowCount == 0 + + loop For each connection + mcpStore->>MCPSvc: disconnect(connection) + MCPSvc->>MCPSvc: transport.onclose = undefined + MCPSvc->>ExtMCP: close() + end + + mcpStore->>mcpStore: connections.clear() + mcpStore->>mcpStore: toolsIndex.clear() + mcpStore->>mcpStore: _connectedServers = [] + + mcpStore->>mcpResStore: clear() + deactivate mcpStore +``` diff --git a/tools/ui/docs/flows/models-flow.md b/tools/ui/docs/flows/models-flow.md new file mode 100644 index 000000000..c3031b729 --- /dev/null +++ b/tools/ui/docs/flows/models-flow.md @@ -0,0 +1,181 @@ +```mermaid +sequenceDiagram + participant UI as 🧩 ModelsSelector + participant Hooks as 🪝 useModelChangeValidation + participant modelsStore as 🗄️ modelsStore + participant serverStore as 🗄️ serverStore + participant convStore as 🗄️ conversationsStore + participant ModelsSvc as ⚙️ ModelsService + participant PropsSvc as ⚙️ PropsService + participant API as 🌐 llama-server + + Note over modelsStore: State:
          models: ModelOption[]
          routerModels: ApiModelDataEntry[]
          selectedModelId, selectedModelName
          loading, updating, error
          modelLoadingStates (Map)
          modelPropsCache (Map)
          propsCacheVersion + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: 🚀 INITIALIZATION (MODEL mode) + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>modelsStore: fetch() + activate modelsStore + modelsStore->>modelsStore: loading = true + + alt serverStore.props not loaded + modelsStore->>serverStore: fetch() + Note over serverStore: → see server-flow.mmd + end + + modelsStore->>ModelsSvc: list() + ModelsSvc->>API: GET /v1/models + API-->>ModelsSvc: ApiModelListResponse {data: [model]} + + modelsStore->>modelsStore: models = $state(mapped) + Note right of modelsStore: Map to ModelOption[]:
          {id, name, model, description, capabilities} + + Note over modelsStore: MODEL mode: Get modalities from serverStore.props + modelsStore->>modelsStore: modelPropsCache.set(model.id, serverStore.props) + modelsStore->>modelsStore: models[0].modalities = props.modalities + + modelsStore->>modelsStore: Auto-select single model + Note right of modelsStore: selectedModelId = models[0].id + modelsStore->>modelsStore: loading = false + deactivate modelsStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: 🚀 INITIALIZATION (ROUTER mode) + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>modelsStore: fetch() + activate modelsStore + modelsStore->>ModelsSvc: list() + ModelsSvc->>API: GET /v1/models + API-->>ModelsSvc: ApiModelListResponse + modelsStore->>modelsStore: models = $state(mapped) + deactivate modelsStore + + Note over UI: After models loaded, layout triggers: + UI->>modelsStore: fetchRouterModels() + activate modelsStore + modelsStore->>ModelsSvc: listRouter() + ModelsSvc->>API: GET /v1/models + API-->>ModelsSvc: ApiRouterModelsListResponse + Note right of API: {data: [{id, status, path, in_cache}]} + modelsStore->>modelsStore: routerModels = $state(data) + + modelsStore->>modelsStore: fetchModalitiesForLoadedModels() + loop each model where status === "loaded" + modelsStore->>PropsSvc: fetchForModel(modelId) + PropsSvc->>API: GET /props?model={modelId} + API-->>PropsSvc: ApiLlamaCppServerProps + modelsStore->>modelsStore: modelPropsCache.set(modelId, props) + end + modelsStore->>modelsStore: propsCacheVersion++ + deactivate modelsStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: 🔄 MODEL SELECTION (ROUTER mode) + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>Hooks: useModelChangeValidation({getRequiredModalities, onSuccess?, onValidationFailure?}) + Note over Hooks: Hook configured per-component:
          ChatForm: getRequiredModalities = usedModalities
          ChatMessage: getRequiredModalities = getModalitiesUpToMessage(msgId) + + UI->>Hooks: handleModelChange(modelId, modelName) + activate Hooks + Hooks->>Hooks: previousSelectedModelId = modelsStore.selectedModelId + Hooks->>modelsStore: isModelLoaded(modelName)? + + alt model NOT loaded + Hooks->>modelsStore: loadModel(modelName) + Note over modelsStore: → see LOAD MODEL section below + end + + Note over Hooks: Always fetch props (from cache or API) + Hooks->>modelsStore: fetchModelProps(modelName) + modelsStore-->>Hooks: props + + Hooks->>convStore: getRequiredModalities() + convStore-->>Hooks: {vision, audio} + + Hooks->>Hooks: Validate: model.modalities ⊇ required? + + alt validation PASSED + Hooks->>modelsStore: selectModelById(modelId) + Hooks-->>UI: return true + else validation FAILED + Hooks->>UI: toast.error("Model doesn't support required modalities") + alt model was just loaded + Hooks->>modelsStore: unloadModel(modelName) + end + alt onValidationFailure provided + Hooks->>modelsStore: selectModelById(previousSelectedModelId) + end + Hooks-->>UI: return false + end + deactivate Hooks + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: ⬆️ LOAD MODEL (ROUTER mode) + %% ═══════════════════════════════════════════════════════════════════════════ + + modelsStore->>modelsStore: loadModel(modelId) + activate modelsStore + + alt already loaded + modelsStore-->>modelsStore: return (no-op) + end + + modelsStore->>modelsStore: modelLoadingStates.set(modelId, true) + modelsStore->>ModelsSvc: load(modelId) + ModelsSvc->>API: POST /models/load {model: modelId} + API-->>ModelsSvc: {status: "loading"} + + modelsStore->>modelsStore: pollForModelStatus(modelId, LOADED) + loop poll every 500ms (max 60 attempts) + modelsStore->>modelsStore: fetchRouterModels() + modelsStore->>ModelsSvc: listRouter() + ModelsSvc->>API: GET /v1/models + API-->>ModelsSvc: models[] + modelsStore->>modelsStore: getModelStatus(modelId) + alt status === LOADED + Note right of modelsStore: break loop + else status === LOADING + Note right of modelsStore: wait 500ms, continue + end + end + + modelsStore->>modelsStore: updateModelModalities(modelId) + modelsStore->>PropsSvc: fetchForModel(modelId) + PropsSvc->>API: GET /props?model={modelId} + API-->>PropsSvc: props with modalities + modelsStore->>modelsStore: modelPropsCache.set(modelId, props) + modelsStore->>modelsStore: propsCacheVersion++ + + modelsStore->>modelsStore: modelLoadingStates.set(modelId, false) + deactivate modelsStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: ⬇️ UNLOAD MODEL (ROUTER mode) + %% ═══════════════════════════════════════════════════════════════════════════ + + modelsStore->>modelsStore: unloadModel(modelId) + activate modelsStore + modelsStore->>modelsStore: modelLoadingStates.set(modelId, true) + modelsStore->>ModelsSvc: unload(modelId) + ModelsSvc->>API: POST /models/unload {model: modelId} + + modelsStore->>modelsStore: pollForModelStatus(modelId, UNLOADED) + loop poll until unloaded + modelsStore->>ModelsSvc: listRouter() + ModelsSvc->>API: GET /v1/models + end + + modelsStore->>modelsStore: modelLoadingStates.set(modelId, false) + deactivate modelsStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: 📊 COMPUTED GETTERS + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over modelsStore: Getters:
          - selectedModel: ModelOption | null
          - loadedModelIds: string[] (from routerModels)
          - loadingModelIds: string[] (from modelLoadingStates)
          - singleModelName: string | null (MODEL mode only) + + Note over modelsStore: Modality helpers:
          - getModelModalities(modelId): {vision, audio}
          - modelSupportsVision(modelId): boolean
          - modelSupportsAudio(modelId): boolean +``` diff --git a/tools/ui/docs/flows/server-flow.md b/tools/ui/docs/flows/server-flow.md new file mode 100644 index 000000000..d6a1611f6 --- /dev/null +++ b/tools/ui/docs/flows/server-flow.md @@ -0,0 +1,76 @@ +```mermaid +sequenceDiagram + participant UI as 🧩 +layout.svelte + participant serverStore as 🗄️ serverStore + participant PropsSvc as ⚙️ PropsService + participant API as 🌐 llama-server + + Note over serverStore: State:
          props: ApiLlamaCppServerProps | null
          loading, error
          role: ServerRole | null (MODEL | ROUTER)
          fetchPromise (deduplication) + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: 🚀 INITIALIZATION + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>serverStore: fetch() + activate serverStore + + alt fetchPromise exists (already fetching) + serverStore-->>UI: return fetchPromise + Note right of serverStore: Deduplicate concurrent calls + end + + serverStore->>serverStore: loading = true + serverStore->>serverStore: fetchPromise = new Promise() + + serverStore->>PropsSvc: fetch() + PropsSvc->>API: GET /props + API-->>PropsSvc: ApiLlamaCppServerProps + Note right of API: {role, model_path, model_alias,
          modalities, default_generation_settings, ...} + + PropsSvc-->>serverStore: props + serverStore->>serverStore: props = $state(data) + + serverStore->>serverStore: detectRole(props) + Note right of serverStore: role = props.role === "router"
          ? ServerRole.ROUTER
          : ServerRole.MODEL + + serverStore->>serverStore: loading = false + serverStore->>serverStore: fetchPromise = null + deactivate serverStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: 📊 COMPUTED GETTERS + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over serverStore: Getters from props: + + rect rgb(240, 255, 240) + Note over serverStore: defaultParams
          → props.default_generation_settings.params
          (temperature, top_p, top_k, etc.) + end + + rect rgb(240, 255, 240) + Note over serverStore: contextSize
          → props.default_generation_settings.n_ctx + end + + rect rgb(255, 240, 240) + Note over serverStore: isRouterMode
          → role === ServerRole.ROUTER + end + + rect rgb(255, 240, 240) + Note over serverStore: isModelMode
          → role === ServerRole.MODEL + end + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: 🔗 RELATIONSHIPS + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over serverStore: Used by: + Note right of serverStore: - modelsStore: role detection, MODEL mode modalities
          - settingsStore: syncWithServerDefaults (defaultParams)
          - chatStore: contextSize for processing state
          - UI components: isRouterMode for conditional rendering + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,API: ❌ ERROR HANDLING + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over serverStore: getErrorMessage(): string | null
          Returns formatted error for UI display + + Note over serverStore: clear(): void
          Resets all state (props, error, loading, role) +``` diff --git a/tools/ui/docs/flows/settings-flow.md b/tools/ui/docs/flows/settings-flow.md new file mode 100644 index 000000000..260713a17 --- /dev/null +++ b/tools/ui/docs/flows/settings-flow.md @@ -0,0 +1,156 @@ +```mermaid +sequenceDiagram + participant UI as 🧩 ChatSettings + participant settingsStore as 🗄️ settingsStore + participant serverStore as 🗄️ serverStore + participant ParamSvc as ⚙️ ParameterSyncService + participant LS as 💾 LocalStorage + + Note over settingsStore: State:
          config: SettingsConfigType
          theme: string ("auto" | "light" | "dark")
          isInitialized: boolean
          userOverrides: Set<string> + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,LS: 🚀 INITIALIZATION + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over settingsStore: Auto-initialized in constructor (browser only) + settingsStore->>settingsStore: initialize() + activate settingsStore + + settingsStore->>settingsStore: loadConfig() + settingsStore->>LS: get("llama-config") + LS-->>settingsStore: StoredConfig | null + + alt config exists + settingsStore->>settingsStore: Merge with SETTING_CONFIG_DEFAULT + Note right of settingsStore: Fill missing keys with defaults + else no config + settingsStore->>settingsStore: config = SETTING_CONFIG_DEFAULT + end + + settingsStore->>LS: get("llama-userOverrides") + LS-->>settingsStore: string[] | null + settingsStore->>settingsStore: userOverrides = new Set(data) + + settingsStore->>settingsStore: loadTheme() + settingsStore->>LS: get("llama-theme") + LS-->>settingsStore: theme | "auto" + + settingsStore->>settingsStore: isInitialized = true + deactivate settingsStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,LS: 🔄 SYNC WITH SERVER DEFAULTS + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over UI: Triggered from +layout.svelte when serverStore.props loaded + UI->>settingsStore: syncWithServerDefaults() + activate settingsStore + + settingsStore->>serverStore: defaultParams + serverStore-->>settingsStore: {temperature, top_p, top_k, ...} + + loop each SYNCABLE_PARAMETER + alt key NOT in userOverrides + settingsStore->>settingsStore: config[key] = serverDefault[key] + Note right of settingsStore: Non-overridden params adopt server default + else key in userOverrides + Note right of settingsStore: Keep user value, skip server default + end + end + + alt serverStore.props has uiSettings + settingsStore->>settingsStore: Apply uiSettings from server + Note right of settingsStore: Server-provided UI settings
          (e.g. showRawOutputSwitch) + end + + settingsStore->>settingsStore: saveConfig() + deactivate settingsStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,LS: ⚙️ UPDATE CONFIG + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>settingsStore: updateConfig(key, value) + activate settingsStore + settingsStore->>settingsStore: config[key] = value + + alt value matches server default for key + settingsStore->>settingsStore: userOverrides.delete(key) + Note right of settingsStore: Matches server default, remove override + else value differs from server default + settingsStore->>settingsStore: userOverrides.add(key) + Note right of settingsStore: Mark as user-modified (won't be overwritten) + end + + settingsStore->>settingsStore: saveConfig() + settingsStore->>LS: set(CONFIG_LOCALSTORAGE_KEY, config) + settingsStore->>LS: set(USER_OVERRIDES_LOCALSTORAGE_KEY, [...userOverrides]) + deactivate settingsStore + + UI->>settingsStore: updateMultipleConfig({key1: val1, key2: val2}) + activate settingsStore + Note right of settingsStore: Batch update, single save + settingsStore->>settingsStore: For each key: config[key] = value + settingsStore->>settingsStore: For each key: userOverrides.add(key) + settingsStore->>settingsStore: saveConfig() + deactivate settingsStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,LS: 🔄 RESET + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>settingsStore: resetConfig() + activate settingsStore + settingsStore->>settingsStore: config = {...SETTING_CONFIG_DEFAULT} + settingsStore->>settingsStore: userOverrides.clear() + Note right of settingsStore: All params reset to defaults
          Next syncWithServerDefaults will adopt server values + settingsStore->>settingsStore: saveConfig() + deactivate settingsStore + + UI->>settingsStore: resetParameterToServerDefault(key) + activate settingsStore + settingsStore->>settingsStore: userOverrides.delete(key) + settingsStore->>serverStore: defaultParams[key] + settingsStore->>settingsStore: config[key] = serverDefault + settingsStore->>settingsStore: saveConfig() + deactivate settingsStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,LS: 🎨 THEME + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>settingsStore: updateTheme(newTheme) + activate settingsStore + settingsStore->>settingsStore: theme = newTheme + settingsStore->>settingsStore: saveTheme() + settingsStore->>LS: set("llama-theme", theme) + deactivate settingsStore + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,LS: 📊 PARAMETER INFO + %% ═══════════════════════════════════════════════════════════════════════════ + + UI->>settingsStore: getParameterInfo(key) + settingsStore->>ParamSvc: getParameterInfo(key, config, serverDefaults, userOverrides) + ParamSvc-->>settingsStore: ParameterInfo + Note right of ParamSvc: {
          currentValue,
          serverDefault,
          isUserOverride: boolean,
          canSync: boolean,
          isDifferentFromServer: boolean
          } + + UI->>settingsStore: getParameterDiff() + settingsStore->>ParamSvc: createParameterDiff(config, serverDefaults, userOverrides) + ParamSvc-->>settingsStore: ParameterDiff[] + Note right of ParamSvc: Array of parameters where user != server + + %% ═══════════════════════════════════════════════════════════════════════════ + Note over UI,LS: 📋 CONFIG CATEGORIES + %% ═══════════════════════════════════════════════════════════════════════════ + + Note over settingsStore: Syncable with server (from /props): + rect rgb(240, 255, 240) + Note over settingsStore: temperature, top_p, top_k, min_p
          repeat_penalty, presence_penalty, frequency_penalty
          dynatemp_range, dynatemp_exponent
          typ_p, xtc_probability, xtc_threshold
          dry_multiplier, dry_base, dry_allowed_length, dry_penalty_last_n + end + + Note over settingsStore: UI-only (not synced): + rect rgb(255, 240, 240) + Note over settingsStore: systemMessage, custom (JSON)
          showStatistics, enableContinueGeneration
          autoMicOnEmpty, disableAutoScroll
          apiKey, pdfAsImage, disableReasoningParsing, showRawOutputSwitch + end +``` diff --git a/tools/ui/eslint.config.js b/tools/ui/eslint.config.js new file mode 100644 index 000000000..185da1dab --- /dev/null +++ b/tools/ui/eslint.config.js @@ -0,0 +1,53 @@ +// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format +import storybook from 'eslint-plugin-storybook'; + +import prettier from 'eslint-config-prettier'; +import { includeIgnoreFile } from '@eslint/compat'; +import js from '@eslint/js'; +import svelte from 'eslint-plugin-svelte'; +import globals from 'globals'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript-eslint'; +import svelteConfig from './svelte.config.js'; + +const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); + +export default ts.config( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node } + }, + rules: { + // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. + // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + 'no-undef': 'off', + 'svelte/no-at-html-tags': 'off', + // This app uses hash-based routing (#/) where resolve() from $app/paths does not apply + 'svelte/no-navigation-without-resolve': 'off', + // Enforce empty line at end of file + 'eol-last': 'error' + } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser, + svelteConfig + } + } + }, + { + // Exclude Storybook files from main ESLint rules + ignores: ['.storybook/**/*'] + }, + storybook.configs['flat/recommended'] +); diff --git a/tools/ui/package-lock.json b/tools/ui/package-lock.json new file mode 100644 index 000000000..bf23307b8 --- /dev/null +++ b/tools/ui/package-lock.json @@ -0,0 +1,10704 @@ +{ + "name": "llama-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "llama-ui", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.1", + "highlight.js": "^11.11.1", + "mode-watcher": "^1.1.0", + "pdfjs-dist": "^5.4.54", + "rehype-highlight": "^7.0.2", + "rehype-stringify": "^10.0.1", + "remark": "^15.0.1", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-html": "^16.0.1", + "remark-rehype": "^11.1.2", + "svelte-sonner": "^1.0.5", + "unist-util-visit": "^5.0.0", + "zod": "^4.2.1" + }, + "devDependencies": { + "@chromatic-com/storybook": "^5.0.0", + "@eslint/compat": "^1.2.5", + "@eslint/js": "^9.18.0", + "@internationalized/date": "^3.10.1", + "@lucide/svelte": "^0.515.0", + "@playwright/test": "^1.49.1", + "@storybook/addon-a11y": "^10.2.4", + "@storybook/addon-docs": "^10.2.4", + "@storybook/addon-svelte-csf": "^5.0.10", + "@storybook/addon-vitest": "^10.2.4", + "@storybook/sveltekit": "^10.2.4", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.48.4", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tailwindcss/forms": "^0.5.9", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^24", + "@vitest/browser": "^3.2.3", + "@vitest/coverage-v8": "^3.2.3", + "bits-ui": "^2.14.4", + "clsx": "^2.1.1", + "dexie": "^4.0.11", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-storybook": "^10.2.4", + "eslint-plugin-svelte": "^3.0.0", + "globals": "^16.0.0", + "http-server": "^14.1.1", + "mdast": "^3.0.0", + "mdsvex": "^0.12.3", + "playwright": "^1.56.1", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.11", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0", + "sass": "^1.93.3", + "storybook": "^10.2.4", + "svelte": "^5.38.2", + "svelte-check": "^4.0.0", + "tailwind-merge": "^3.3.1", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.0.0", + "tw-animate-css": "^1.3.5", + "typescript": "^5.0.0", + "typescript-eslint": "^8.20.0", + "unified": "^11.0.5", + "uuid": "^13.0.0", + "vite": "^7.2.2", + "vite-plugin-devtools-json": "^0.2.0", + "vitest": "^3.2.3", + "vitest-browser-svelte": "^0.1.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@chromatic-com/storybook": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.0.0.tgz", + "integrity": "sha512-8wUsqL8kg6R5ue8XNE7Jv/iD1SuE4+6EXMIGIuE+T2loBITEACLfC3V8W44NJviCLusZRMWbzICddz0nU0bFaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@neoconfetti/react": "^1.0.0", + "chromatic": "^13.3.4", + "filesize": "^10.0.12", + "jsonfile": "^6.1.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20.0.0", + "yarn": ">=1.22.18" + }, + "peerDependencies": { + "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/compat": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.1.tgz", + "integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^8.40 || 9" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "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.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", + "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz", + "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.2", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@hono/node-server": { + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@internationalized/date": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz", + "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lucide/svelte": { + "version": "0.515.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.515.0.tgz", + "integrity": "sha512-CEAyqcZmNBfYzVgaRmK2RFJP5tnbXxekRyDk0XX/eZQRfsJmkDvmQwXNX8C869BgNeryzmrRyjHhUL6g9ZOHNA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "node_modules/@mdx-js/react": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz", + "integrity": "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.76", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.76.tgz", + "integrity": "sha512-YIk5okeNN53GzjvWmAyCQFE9xrLeQXzYpudX4TiLvqaz9SqXgIgxIuKPe4DKyB5nccsQMIev7JGKTzZaN5rFdw==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.76", + "@napi-rs/canvas-darwin-arm64": "0.1.76", + "@napi-rs/canvas-darwin-x64": "0.1.76", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.76", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.76", + "@napi-rs/canvas-linux-arm64-musl": "0.1.76", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.76", + "@napi-rs/canvas-linux-x64-gnu": "0.1.76", + "@napi-rs/canvas-linux-x64-musl": "0.1.76", + "@napi-rs/canvas-win32-x64-msvc": "0.1.76" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.76", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.76.tgz", + "integrity": "sha512-7EAfkLBQo2QoEzpHdInFbfEUYTXsiO2hvtFo1D9zfTzcQM8n5piZdOpJ3EIkmpe8yLoSV8HLyUQtq4bv11x6Tg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.76", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.76.tgz", + "integrity": "sha512-Cs8WRMzaWSJWeWY8tvnCe+TuduHUbB0xFhZ0FmOrNy2prPxT4A6aU3FQu8hR9XJw8kKZ7v902wzaDmy9SdhG8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.76", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.76.tgz", + "integrity": "sha512-ya+T6gV9XAq7YAnMa2fKhWXAuRR5cpRny2IoHacoMxgtOARnUkJO/k3hIb52FtMoq7UxLi5+IFGVHU6ZiMu4Ag==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.76", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.76.tgz", + "integrity": "sha512-fgnPb+FKVuixACvkHGldJqYXExORBwvqGgL0K80uE6SGH2t0UKD2auHw2CtBy14DUzfg82PkupO2ix2w7kB+Xw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.76", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.76.tgz", + "integrity": "sha512-r8OxIenvBPOa4I014k1ZWTCz2dB0ZTsxMP7+ovMOKO7jkl1Z+YZo2OTAqxArpMhN0wdEeI3Lw9zUcn2HgwEgDA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.76", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.76.tgz", + "integrity": "sha512-smxwzKfHYaOYG7QXUuDPrFEC7WqjL3Lx4AM6mk8/FxDAS+8o0eoZJwSu+zXsaBLimEQUozEYgEGtJ2JJ0RdL4A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.76", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.76.tgz", + "integrity": "sha512-G2PsFwsP+r4syEoNLStV3n1wtNAClwf8s/qB57bexG08R4f4WaiBd+x+d4iYS0Y5o90YIEm8/ewZn4bLIa0wNQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.76", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.76.tgz", + "integrity": "sha512-SNK+vgge4DnuONYdYE3Y09LivGgUiUPQDU+PdGNZJIzIi0hRDLcA59eag8LGeQfPmJW84c1aZD04voihybKFog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.76", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.76.tgz", + "integrity": "sha512-tWHLBI9iVoR1NsfpHz1MGERTkqcca8akbH/CzX6JQUNC+lJOeYYXeRuK8hKqMIg1LI+4QOMAtHNVeZu8NvjEug==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.76", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.76.tgz", + "integrity": "sha512-ifM5HOGw2hP5QLQzCB41Riw3Pq5yKAAjZpn+lJC0sYBmyS2s/Kq6KpTOKxf0CuptkI1wMcRcYQfhLRdeWiYvIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@neoconfetti/react": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@neoconfetti/react/-/react-1.0.0.tgz", + "integrity": "sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/addon-a11y": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.2.4.tgz", + "integrity": "sha512-VGhdZ+iP2l/CSulIKV2kt3SMWVHntOigqWqGkNYf6YNYofynUYEKdsNqBvHx4ySuNEl/eXJ8LRO8FKYnU7LxZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "axe-core": "^4.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.2.4" + } + }, + "node_modules/@storybook/addon-docs": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.4.tgz", + "integrity": "sha512-FzscAmdBiOGnGrxiEM+8eTg43kjqgjLfObg+lbJVRR/a0DmZ3xfAPNB0+VKYQbN0FacNcWLM9LZ/7U0hRBPBnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdx-js/react": "^3.0.0", + "@storybook/csf-plugin": "10.2.4", + "@storybook/icons": "^2.0.1", + "@storybook/react-dom-shim": "10.2.4", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.2.4" + } + }, + "node_modules/@storybook/addon-svelte-csf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/@storybook/addon-svelte-csf/-/addon-svelte-csf-5.0.10.tgz", + "integrity": "sha512-poSvTS7VdaQ42ZoqW5e4+2Hv1iLO0mekH9fwn/QuBNse48R4WlTyR8XFbHRTfatl9gdc9ZYC4uWzazrmV6zGIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/csf": "^0.1.13", + "dedent": "^1.5.3", + "es-toolkit": "^1.26.1", + "esrap": "^1.2.2", + "magic-string": "^0.30.12", + "svelte-ast-print": "^0.4.0", + "zimmerframe": "^1.1.2" + }, + "peerDependencies": { + "@storybook/svelte": "^0.0.0-0 || ^8.2.0 || ^9.0.0 || ^9.1.0-0 || ^10.0.0-0", + "@sveltejs/vite-plugin-svelte": "^4.0.0 || ^5.0.0 || ^6.0.0", + "storybook": "^0.0.0-0 || ^8.2.0 || ^9.0.0 || ^9.1.0-0 || ^10.0.0-0", + "svelte": "^5.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@storybook/addon-vitest": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.2.4.tgz", + "integrity": "sha512-BT1iP89U4wcbpzTURU8WYTAeUcdNh4WIt0BqsnATmMwR/jKNJW6QgXCVqGQTSpRjWj40hX5e2JkQYCNXdjKsPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@vitest/browser": "^3.0.0 || ^4.0.0", + "@vitest/browser-playwright": "^4.0.0", + "@vitest/runner": "^3.0.0 || ^4.0.0", + "storybook": "^10.2.4", + "vitest": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/runner": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@storybook/builder-vite": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.2.4.tgz", + "integrity": "sha512-/hcT1xj3CL5GkJ5v5/EguZdttDwNE6weNXK7vKzp034tnGcLycOossDsTiUQkBowSL+Ylc8aKj+ZgvddPNfOig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/csf-plugin": "10.2.4", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.2.4", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@storybook/csf": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.1.13.tgz", + "integrity": "sha512-7xOOwCLGB3ebM87eemep89MYRFTko+D8qE7EdAAq74lgdqRR5cOUtYWJLjO2dLtP94nqoOdHJo6MdLLKzg412Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^2.19.0" + } + }, + "node_modules/@storybook/csf-plugin": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.4.tgz", + "integrity": "sha512-kupPQEV+4N9mzsZHYaokvhO/KHBjYdWda9PNmPQwy0TR7r2mzthgaNH72TjmgN1L6DIbsuyOG1wtczcPJn4+Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "unplugin": "^2.3.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "esbuild": "*", + "rollup": "*", + "storybook": "^10.2.4", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "esbuild": { + "optional": true + }, + "rollup": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@storybook/global": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/icons": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz", + "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@storybook/react-dom-shim": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.4.tgz", + "integrity": "sha512-i22OtrZ7GeZPt/odLf0vqyDhRSKyaLsHkkKSBcANQfzRRnBZmiz2FchOtWm9uvoDWybQsTruZq7kTdtpEhwyGw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.2.4" + } + }, + "node_modules/@storybook/svelte": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/@storybook/svelte/-/svelte-10.2.4.tgz", + "integrity": "sha512-W9R51zUCd2iHOQBg/D93+bdpYv6kbtFx+kft5X8lPKQl6yEu0aKs9i5N5GyCASOhIApgx/tkqZIJ7vgM4cqrHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-dedent": "^2.0.0", + "type-fest": "~2.19" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.2.4", + "svelte": "^5.0.0" + } + }, + "node_modules/@storybook/svelte-vite": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/@storybook/svelte-vite/-/svelte-vite-10.2.4.tgz", + "integrity": "sha512-FMgKMRdoZFDwPD6eIDMldcgp6d6NtIGuXyUJjb29qLias/gE5TI6hg+cWmmWXQRTrXwdyepeMBmIfRcZbB6REQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/builder-vite": "10.2.4", + "@storybook/svelte": "10.2.4", + "magic-string": "^0.30.0", + "svelte2tsx": "^0.7.44", + "typescript": "^4.9.4 || ^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "storybook": "^10.2.4", + "svelte": "^5.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@storybook/sveltekit": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/@storybook/sveltekit/-/sveltekit-10.2.4.tgz", + "integrity": "sha512-1qDX35iSJHWo1AOd7HMzJtCHBfgahXqTWNiyZa/JMEKJ3qC1otaU8XMmTjsZ6fCRF99piNdgqtWM8+s1TJOldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/builder-vite": "10.2.4", + "@storybook/svelte": "10.2.4", + "@storybook/svelte-vite": "10.2.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.2.4", + "svelte": "^5.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", + "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.59.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz", + "integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz", + "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.17", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.0.tgz", + "integrity": "sha512-iwQ8Z4ET6ZFSt/gC+tVfcsSBHwsqc6RumSaiLUkAurW3BCpJam65cmHw0oOlDMTO0u+PZi9hilBRYN+LZNHTUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", + "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", + "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.30.1", + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.11" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz", + "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.11", + "@tailwindcss/oxide-darwin-arm64": "4.1.11", + "@tailwindcss/oxide-darwin-x64": "4.1.11", + "@tailwindcss/oxide-freebsd-x64": "4.1.11", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", + "@tailwindcss/oxide-linux-x64-musl": "4.1.11", + "@tailwindcss/oxide-wasm32-wasi": "4.1.11", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz", + "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz", + "integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz", + "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz", + "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz", + "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz", + "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz", + "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz", + "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz", + "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz", + "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@emnapi/wasi-threads": "^1.0.2", + "@napi-rs/wasm-runtime": "^0.2.11", + "@tybys/wasm-util": "^0.9.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", + "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz", + "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.11.tgz", + "integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.11", + "@tailwindcss/oxide": "4.1.11", + "tailwindcss": "4.1.11" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/katex": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.10.tgz", + "integrity": "sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/type-utils": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.0", + "@typescript-eslint/types": "^8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.0", + "@typescript-eslint/tsconfig-utils": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", + "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitest/browser": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.4.tgz", + "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/user-event": "^14.6.1", + "@vitest/mocker": "3.2.4", + "@vitest/utils": "3.2.4", + "magic-string": "^0.30.17", + "sirv": "^3.0.1", + "tinyrainbow": "^2.0.0", + "ws": "^8.18.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "3.2.4", + "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", + "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bits-ui": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz", + "integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, + "node_modules/bits-ui/node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, + "node_modules/bits-ui/node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz", + "integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromatic": { + "version": "13.3.5", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.5.tgz", + "integrity": "sha512-MzPhxpl838qJUo0A55osCF2ifwPbjcIPeElr1d4SHcjnHoIcg7l1syJDrAYK/a+PcCBrOGi06jPNpQAln5hWgw==", + "dev": true, + "license": "MIT", + "bin": { + "chroma": "dist/bin.js", + "chromatic": "dist/bin.js", + "chromatic-cli": "dist/bin.js" + }, + "peerDependencies": { + "@chromatic-com/cypress": "^0.*.* || ^1.0.0", + "@chromatic-com/playwright": "^0.*.* || ^1.0.0" + }, + "peerDependenciesMeta": { + "@chromatic-com/cypress": { + "optional": true + }, + "@chromatic-com/playwright": { + "optional": true + } + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dedent": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/dedent-js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz", + "integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dexie": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.11.tgz", + "integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", + "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.39.7", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.7.tgz", + "integrity": "sha512-ek/wWryKouBrZIjkwW2BFf91CWOIMvoy2AE5YYgUrfWsJQM2Su1LoLtrw8uusEpN9RfqLlV/0FVNjT0WMv8Bxw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "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.2", + "@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" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-storybook": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.2.4.tgz", + "integrity": "sha512-D8a6Y+iun2MSOpgps0Vd/t8y9Y5ZZ7O2VeKqw2PCv2+b7yInqogOS2VBMSRZVfP8TTGQgDpbUK67k7KZEUC7Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.48.0" + }, + "peerDependencies": { + "eslint": ">=8", + "storybook": "^10.2.4" + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.15.0.tgz", + "integrity": "sha512-QKB7zqfuB8aChOfBTComgDptMf2yxiJx7FE04nneCmtQzgTHvY8UJkuh8J2Rz7KB9FFV9aTHX6r7rdYGvG8T9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.9.tgz", + "integrity": "sha512-3OMlcd0a03UGuZpPeUC1HxR3nA23l+HEyCiZw3b3FumJIN9KphoGzDJKMXI1S72jVS1dsenDyQC0kJlO1U9E1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.0.tgz", + "integrity": "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/filesize": { + "version": "10.1.6", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", + "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 10.4.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/hast-util-from-html/node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html/node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/hono": { + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "license": "MIT" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/katex": { + "version": "0.16.22", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", + "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loupe": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz", + "integrity": "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast/-/mdast-3.0.0.tgz", + "integrity": "sha512-xySmf8g4fPKMeC07jXGz971EkLbWAJ83s4US2Tj9lEdnZ142UP5grN73H1Xd3HzrdbU5o9GYYP/y8F9ZSwLE9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/mdast-util-from-markdown/node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdsvex": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/mdsvex/-/mdsvex-0.12.6.tgz", + "integrity": "sha512-pupx2gzWh3hDtm/iDW4WuCpljmyHbHi34r7ktOqpPGvyiM4MyfNgdJ3qMizXdgCErmvYC9Nn/qyjePy+4ss9Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.4", + "@types/unist": "^2.0.3", + "prism-svelte": "^0.4.7", + "prismjs": "^1.17.1", + "unist-util-visit": "^2.0.1", + "vfile-message": "^2.0.4" + }, + "peerDependencies": { + "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0-next.120" + } + }, + "node_modules/mdsvex/node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdsvex/node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdsvex/node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mode-watcher": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz", + "integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==", + "license": "MIT", + "dependencies": { + "runed": "^0.25.0", + "svelte-toolbelt": "^0.7.1" + }, + "peerDependencies": { + "svelte": "^5.27.0" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.54", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.54.tgz", + "integrity": "sha512-TBAiTfQw89gU/Z4LW98Vahzd2/LoCFprVGvGbTgFt+QCB1F+woyOPmNNVgLa6djX9Z9GGTnj7qE1UzpOVJiINw==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.74" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.0.tgz", + "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prism-svelte": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/prism-svelte/-/prism-svelte-0.4.7.tgz", + "integrity": "sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", + "integrity": "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-html": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/remark-html/-/remark-html-16.0.1.tgz", + "integrity": "sha512-B9JqA5i0qZe0Nsf49q3OXyGvyXuZFDzAP2iOFLEumymuYJITVpiH1IgsTEwTpdptDmZlMDMWeDmSawdaJIGCXQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "hast-util-sanitize": "^5.0.0", + "hast-util-to-html": "^9.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/runed": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz", + "integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.93.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.3.tgz", + "integrity": "sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", + "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/storybook": { + "version": "10.3.3", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.3.3.tgz", + "integrity": "sha512-tMoRAts9EVqf+mEMPLC6z1DPyHbcPe+CV1MhLN55IKsl0HxNjvVGK44rVPSePbltPE6vIsn4bdRj6CCUt8SJwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/user-event": "^14.6.1", + "@vitest/expect": "3.2.4", + "@vitest/spy": "3.2.4", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "open": "^10.2.0", + "recast": "^0.23.5", + "semver": "^7.7.3", + "use-sync-external-store": "^1.5.0", + "ws": "^8.18.0" + }, + "bin": { + "storybook": "dist/bin/dispatcher.js" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-to-object": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.9.tgz", + "integrity": "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svelte": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz", + "integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-ast-print": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/svelte-ast-print/-/svelte-ast-print-0.4.2.tgz", + "integrity": "sha512-hRHHufbJoArFmDYQKCpCvc0xUuIEfwYksvyLYEQyH+1xb5LD5sM/IthfooCdXZQtOIqXz6xm7NmaqdfwG4kh6w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/xeho91" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/xeho91" + } + ], + "license": "MIT", + "dependencies": { + "esrap": "1.2.2", + "zimmerframe": "1.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/svelte-ast-print/node_modules/esrap": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.2.2.tgz", + "integrity": "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@types/estree": "^1.0.1" + } + }, + "node_modules/svelte-check": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.0.tgz", + "integrity": "sha512-Iz8dFXzBNAM7XlEIsUjUGQhbEE+Pvv9odb9+0+ITTgFWZBGeJRRYqHUUglwe2EkLD5LIsQaAc4IUJyvtKuOO5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.1.tgz", + "integrity": "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.24.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/svelte-sonner": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.5.tgz", + "integrity": "sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg==", + "license": "MIT", + "dependencies": { + "runed": "^0.28.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/svelte-sonner/node_modules/runed": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", + "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/svelte-toolbelt": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz", + "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==", + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.23.2", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/svelte-toolbelt/node_modules/runed": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz", + "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/svelte/node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/svelte2tsx": { + "version": "0.7.47", + "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.47.tgz", + "integrity": "sha512-1aw/MFKVPM96OBevJdC12do2an9t5Zwr3Va9amLgTLpJje36ibD1iIHpuqCYWUrdR9vw6g6btKGQPmsqE8ZYCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dedent-js": "^1.0.1", + "scule": "^1.3.0" + }, + "peerDependencies": { + "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", + "typescript": "^4.9.4 || ^5.0.0" + } + }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-variants": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", + "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", + "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", + "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.5.tgz", + "integrity": "sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", + "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unified/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/unist-util-stringify-position": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", + "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/unist-util-visit/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/vfile-message": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", + "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/vfile/node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile/node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-plugin-devtools-json": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/vite-plugin-devtools-json/-/vite-plugin-devtools-json-0.2.1.tgz", + "integrity": "sha512-5aiNvf/iLTuLR1dUqoI5CLLGgeK2hd6u+tA+RIp7GUZDyAcM6ECaUEWOOtGpidbcxbkKq++KtmSqA3jhMbPwMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "uuid": "^11.1.0" + }, + "peerDependencies": { + "vite": "^2.7.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/vite-plugin-devtools-json/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest-browser-svelte": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/vitest-browser-svelte/-/vitest-browser-svelte-0.1.0.tgz", + "integrity": "sha512-YB6ZUZZQNqU1T9NzvTEDpwpPv35Ng1NZMPBh81zDrLEdOgROGE6nJb79NWb1Eu/a8VkHifqArpOZfJfALge6xQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "^2.1.0 || ^3.0.0-0", + "svelte": ">3.0.0", + "vitest": "^2.1.0 || ^3.0.0-0" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", + "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/tools/ui/package.json b/tools/ui/package.json new file mode 100644 index 000000000..5a1cec666 --- /dev/null +++ b/tools/ui/package.json @@ -0,0 +1,96 @@ +{ + "name": "llama-ui", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "bash scripts/dev.sh", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "reset": "rm -rf .svelte-kit node_modules", + "format": "prettier --write .", + "lint": "prettier --check . && eslint .", + "test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e", + "test:e2e": "playwright test", + "test:client": "vitest --project=client", + "test:unit": "vitest --project=unit", + "test:ui": "vitest --project=ui", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "cleanup": "rm -rf .svelte-kit build node_modules test-results" + }, + "devDependencies": { + "@chromatic-com/storybook": "^5.0.0", + "@eslint/compat": "^1.2.5", + "@eslint/js": "^9.18.0", + "@internationalized/date": "^3.10.1", + "@lucide/svelte": "^0.515.0", + "@playwright/test": "^1.49.1", + "@storybook/addon-a11y": "^10.2.4", + "@storybook/addon-docs": "^10.2.4", + "@storybook/addon-svelte-csf": "^5.0.10", + "@storybook/addon-vitest": "^10.2.4", + "@storybook/sveltekit": "^10.2.4", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.48.4", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tailwindcss/forms": "^0.5.9", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^24", + "@vitest/browser": "^3.2.3", + "@vitest/coverage-v8": "^3.2.3", + "bits-ui": "^2.14.4", + "clsx": "^2.1.1", + "dexie": "^4.0.11", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-storybook": "^10.2.4", + "eslint-plugin-svelte": "^3.0.0", + "globals": "^16.0.0", + "http-server": "^14.1.1", + "mdast": "^3.0.0", + "mdsvex": "^0.12.3", + "playwright": "^1.56.1", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.11", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0", + "sass": "^1.93.3", + "storybook": "^10.2.4", + "svelte": "^5.38.2", + "svelte-check": "^4.0.0", + "tailwind-merge": "^3.3.1", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.0.0", + "tw-animate-css": "^1.3.5", + "typescript": "^5.0.0", + "typescript-eslint": "^8.20.0", + "unified": "^11.0.5", + "uuid": "^13.0.0", + "vite": "^7.2.2", + "vite-plugin-devtools-json": "^0.2.0", + "vitest": "^3.2.3", + "vitest-browser-svelte": "^0.1.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.1", + "highlight.js": "^11.11.1", + "mode-watcher": "^1.1.0", + "pdfjs-dist": "^5.4.54", + "rehype-highlight": "^7.0.2", + "rehype-stringify": "^10.0.1", + "remark": "^15.0.1", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-html": "^16.0.1", + "remark-rehype": "^11.1.2", + "svelte-sonner": "^1.0.5", + "unist-util-visit": "^5.0.0", + "zod": "^4.2.1" + } +} diff --git a/tools/ui/playwright.config.ts b/tools/ui/playwright.config.ts new file mode 100644 index 000000000..178fd7ba8 --- /dev/null +++ b/tools/ui/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + webServer: { + command: 'npm run build && http-server ../../build/tools/ui/dist -p 8181', + port: 8181, + timeout: 120000, + reuseExistingServer: false + }, + testDir: 'tests/e2e' +}); diff --git a/tools/ui/scripts/dev.sh b/tools/ui/scripts/dev.sh new file mode 100644 index 000000000..9256f255a --- /dev/null +++ b/tools/ui/scripts/dev.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# Development script for llama-ui +# +# This script starts the llama-ui development servers (Storybook and Vite). +# Note: You need to start llama-server separately. +# +# Usage: +# bash scripts/dev.sh +# npm run dev + +cd ../../ + +# Check and install git hooks if missing +check_and_install_hooks() { + local hooks_missing=false + + # Check for required hooks + if [ ! -f ".git/hooks/pre-commit" ] || [ ! -f ".git/hooks/pre-push" ] || [ ! -f ".git/hooks/post-push" ]; then + hooks_missing=true + fi + + if [ "$hooks_missing" = true ]; then + echo "🔧 Git hooks missing, installing them..." + cd tools/ui + if bash scripts/install-git-hooks.sh; then + echo "✅ Git hooks installed successfully" + else + echo "⚠️ Failed to install git hooks, continuing anyway..." + fi + cd ../../ + else + echo "✅ Git hooks already installed" + fi +} + +# Install git hooks if needed +check_and_install_hooks + +# Cleanup function +cleanup() { + echo "🧹 Cleaning up..." + exit +} + +# Set up signal handlers +trap cleanup SIGINT SIGTERM + +echo "🚀 Starting development servers..." +echo "📝 Note: Make sure to start llama-server separately if needed" +cd tools/ui +# Use --insecure-http-parser to handle malformed HTTP responses from llama-server +# (some responses have both Content-Length and Transfer-Encoding headers) +storybook dev -p 6006 --ci & NODE_OPTIONS="--insecure-http-parser" vite dev --host 0.0.0.0 & + +# Wait for all background processes +wait diff --git a/tools/ui/scripts/install-git-hooks.sh b/tools/ui/scripts/install-git-hooks.sh new file mode 100755 index 000000000..213feb08d --- /dev/null +++ b/tools/ui/scripts/install-git-hooks.sh @@ -0,0 +1,78 @@ +#!/bin/bash + +# Script to install pre-commit hook for llama-ui +# Pre-commit: formats, checks, and builds the UI app + +REPO_ROOT=$(git rev-parse --show-toplevel) +PRE_COMMIT_HOOK="$REPO_ROOT/.git/hooks/pre-commit" + +echo "Installing pre-commit hook for llama-ui..." + +# Create the pre-commit hook +cat > "$PRE_COMMIT_HOOK" << 'EOF' +#!/bin/bash + +# Check if there are any changes in the tools/ui directory +if git diff --cached --name-only | grep -q "^tools/ui/"; then + REPO_ROOT=$(git rev-parse --show-toplevel) + cd "$REPO_ROOT/tools/ui" + + # Check if package.json exists + if [ ! -f "package.json" ]; then + echo "Error: package.json not found in tools/ui" + exit 1 + fi + + echo "Formatting and checking llama-ui code..." + + # Run the format command + npm run format + if [ $? -ne 0 ]; then + echo "Error: npm run format failed" + exit 1 + fi + + # Run the lint command + npm run lint + if [ $? -ne 0 ]; then + echo "Error: npm run lint failed" + exit 1 + fi + + # Run the check command + npm run check + if [ $? -ne 0 ]; then + echo "Error: npm run check failed" + exit 1 + fi + + echo "✅ llama-ui code formatted and checked successfully" + + # Build the llama-ui + echo "Building llama-ui..." + npm run build + if [ $? -ne 0 ]; then + echo "❌ npm run build failed" + exit 1 + fi + + echo "✅ llama-ui built successfully" +fi + +exit 0 +EOF + +# Make hook executable +chmod +x "$PRE_COMMIT_HOOK" + +if [ $? -eq 0 ]; then + echo "✅ Git hook installed successfully!" + echo " Pre-commit: $PRE_COMMIT_HOOK" + echo "" + echo "The hook will automatically:" + echo " • Format, lint and check llama-ui code before commits" + echo " • Build llama-ui" +else + echo "❌ Failed to make hook executable" + exit 1 +fi diff --git a/tools/ui/scripts/vite-plugin-llama-cpp-build.ts b/tools/ui/scripts/vite-plugin-llama-cpp-build.ts new file mode 100644 index 000000000..ddf6fa1e5 --- /dev/null +++ b/tools/ui/scripts/vite-plugin-llama-cpp-build.ts @@ -0,0 +1,103 @@ +import { + readFileSync, + writeFileSync, + existsSync, + readdirSync, + copyFileSync, + rmSync, + unlinkSync +} from 'fs'; +import { resolve } from 'path'; +import type { Plugin } from 'vite'; + +const GUIDE_FOR_FRONTEND = ` + +`.trim(); + +const OUTPUT_DIR = '../../build/tools/ui/dist'; + +export function llamaCppBuildPlugin(): Plugin { + return { + name: 'llamacpp:build', + apply: 'build', + closeBundle() { + setTimeout(() => { + try { + const outDir = resolve(OUTPUT_DIR); + const indexPath = resolve(outDir, 'index.html'); + if (!existsSync(indexPath)) return; + + let content = readFileSync(indexPath, 'utf-8'); + + // Inline favicon as base64 data URL + const faviconPath = resolve('static/favicon.svg'); + if (existsSync(faviconPath)) { + const faviconContent = readFileSync(faviconPath, 'utf-8'); + const faviconBase64 = Buffer.from(faviconContent).toString('base64'); + const faviconDataUrl = `data:image/svg+xml;base64,${faviconBase64}`; + content = content.replace(/href="[^"]*favicon\.svg"/g, `href="${faviconDataUrl}"`); + console.log('✓ Inlined favicon.svg as base64 data URL'); + } + + content = content.replace(/\r/g, ''); + content = GUIDE_FOR_FRONTEND + '\n' + content; + content = content.replace(/\/_app\/immutable\/bundle\.[^"]+\.js/g, './bundle.js'); + content = content.replace( + /\/_app\/immutable\/assets\/bundle\.[^"]+\.css/g, + './bundle.css' + ); + content = content.replace(/__sveltekit_[a-z0-9]+/g, '__sveltekit__'); + + writeFileSync(indexPath, content, 'utf-8'); + console.log('✓ Updated index.html'); + + // Copy bundle.*.js -> bundle.js at output root + const immutableDir = resolve(outDir, '_app/immutable'); + const bundleDir = resolve(outDir, '_app/immutable/assets'); + + if (existsSync(immutableDir)) { + const jsFiles = readdirSync(immutableDir).filter((f) => f.match(/^bundle\..+\.js$/)); + if (jsFiles.length > 0) { + copyFileSync(resolve(immutableDir, jsFiles[0]), resolve(outDir, 'bundle.js')); + // Normalize __sveltekit_ to __sveltekit__ in bundle.js + const bundleJsPath = resolve(outDir, 'bundle.js'); + let bundleJs = readFileSync(bundleJsPath, 'utf-8'); + bundleJs = bundleJs.replace(/__sveltekit_[a-z0-9]+/g, '__sveltekit__'); + writeFileSync(bundleJsPath, bundleJs, 'utf-8'); + console.log(`✓ Copied ${jsFiles[0]} -> bundle.js`); + } + } + + // Copy bundle.*.css -> bundle.css at output root + if (existsSync(bundleDir)) { + const cssFiles = readdirSync(bundleDir).filter((f) => f.match(/^bundle\..+\.css$/)); + if (cssFiles.length > 0) { + copyFileSync(resolve(bundleDir, cssFiles[0]), resolve(outDir, 'bundle.css')); + console.log(`✓ Copied ${cssFiles[0]} -> bundle.css`); + } + } + + // Cleanup: remove _app directory, favicon.svg, and legacy index.html.gz + const appDir = resolve(outDir, '_app'); + if (existsSync(appDir)) { + rmSync(appDir, { recursive: true, force: true }); + console.log('✓ Removed _app directory'); + } + + const faviconOut = resolve(outDir, 'favicon.svg'); + if (existsSync(faviconOut)) { + unlinkSync(faviconOut); + console.log('✓ Removed favicon.svg'); + } + } catch (error) { + console.error('Failed to process build output:', error); + } + }, 100); + } + }; +} diff --git a/tools/ui/src/app.css b/tools/ui/src/app.css new file mode 100644 index 000000000..6e29b70a3 --- /dev/null +++ b/tools/ui/src/app.css @@ -0,0 +1,186 @@ +@import 'tailwindcss'; + +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.95 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.95 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.875 0 0); + --input: oklch(0.92 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + --code-background: oklch(0.985 0 0); + --code-foreground: oklch(0.145 0 0); + --layer-popover: 1000000; + + --chat-form-area-height: 8rem; + --chat-form-area-offset: 2rem; + --max-message-height: max(24rem, min(80dvh, calc(100dvh - var(--chat-form-area-height) - 12rem))); +} + +@media (min-width: 640px) { + :root { + --chat-form-area-height: 24rem; + --chat-form-area-offset: 12rem; + } +} + +.dark { + --background: oklch(0.16 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.29 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 30%); + --input: oklch(1 0 0 / 30%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.2 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + --code-background: oklch(0.225 0 0); + --code-foreground: oklch(0.875 0 0); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + body { + @apply bg-background text-foreground; + scrollbar-width: thin; + scrollbar-gutter: stable; + } + + /* Global scrollbar styling - visible only on hover */ + * { + scrollbar-width: thin; + scrollbar-color: transparent transparent; + transition: scrollbar-color 0.2s ease; + } + + *:hover { + scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent; + } + + *::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + *::-webkit-scrollbar-track { + background: transparent; + } + + *::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 3px; + transition: background 0.2s ease; + } + + *:hover::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.3); + } + + *::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.5); + } +} + +@layer utilities { + .scrollbar-hide { + /* Hide scrollbar for Chrome, Safari and Opera */ + &::-webkit-scrollbar { + display: none; + } + /* Hide scrollbar for IE, Edge and Firefox */ + -ms-overflow-style: none; + scrollbar-width: none; + } +} diff --git a/tools/ui/src/app.d.ts b/tools/ui/src/app.d.ts new file mode 100644 index 000000000..f5af7323c --- /dev/null +++ b/tools/ui/src/app.d.ts @@ -0,0 +1,131 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces + +// Import chat types from dedicated module + +import type { + // API types + ApiChatCompletionRequest, + ApiChatCompletionResponse, + ApiChatCompletionStreamChunk, + ApiChatCompletionToolCall, + ApiChatCompletionToolCallDelta, + ApiChatMessageData, + ApiChatMessageContentPart, + ApiContextSizeError, + ApiErrorResponse, + ApiLlamaCppServerProps, + ApiModelDataEntry, + ApiModelListResponse, + ApiProcessingState, + ApiRouterModelMeta, + ApiRouterModelsLoadRequest, + ApiRouterModelsLoadResponse, + ApiRouterModelsStatusRequest, + ApiRouterModelsStatusResponse, + ApiRouterModelsListResponse, + ApiRouterModelsUnloadRequest, + ApiRouterModelsUnloadResponse, + // Chat types + ChatAttachmentDisplayItem, + ChatMessageType, + ChatRole, + ChatUploadedFile, + ChatMessageSiblingInfo, + ChatMessagePromptProgress, + ChatMessageTimings, + // Database types + DatabaseConversation, + DatabaseMessage, + DatabaseMessageExtra, + DatabaseMessageExtraAudioFile, + DatabaseMessageExtraImageFile, + DatabaseMessageExtraTextFile, + DatabaseMessageExtraPdfFile, + DatabaseMessageExtraLegacyContext, + ExportedConversation, + ExportedConversations, + // Model types + ModelModalities, + ModelOption, + // Settings types + SettingsChatServiceOptions, + SettingsConfigValue, + SettingsFieldConfig, + SettingsConfigType +} from '$lib/types'; + +import { ServerRole, ServerModelStatus, ModelModality } from '$lib/enums'; + +declare global { + // namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + // } + + export { + // API types + ApiChatCompletionRequest, + ApiChatCompletionResponse, + ApiChatCompletionStreamChunk, + ApiChatCompletionToolCall, + ApiChatCompletionToolCallDelta, + ApiChatMessageData, + ApiChatMessageContentPart, + ApiContextSizeError, + ApiErrorResponse, + ApiLlamaCppServerProps, + ApiModelDataEntry, + ApiModelListResponse, + ApiProcessingState, + ApiRouterModelMeta, + ApiRouterModelsLoadRequest, + ApiRouterModelsLoadResponse, + ApiRouterModelsStatusRequest, + ApiRouterModelsStatusResponse, + ApiRouterModelsListResponse, + ApiRouterModelsUnloadRequest, + ApiRouterModelsUnloadResponse, + // Chat types + ChatAttachmentDisplayItem, + ChatMessagePromptProgress, + ChatMessageSiblingInfo, + ChatMessageTimings, + ChatMessageType, + ChatRole, + ChatUploadedFile, + // Database types + DatabaseConversation, + DatabaseMessage, + DatabaseMessageExtra, + DatabaseMessageExtraAudioFile, + DatabaseMessageExtraImageFile, + DatabaseMessageExtraTextFile, + DatabaseMessageExtraPdfFile, + DatabaseMessageExtraLegacyContext, + ExportedConversation, + ExportedConversations, + // Enum types + ModelModality, + ServerRole, + ServerModelStatus, + // Model types + ModelModalities, + ModelOption, + // Settings types + SettingsChatServiceOptions, + SettingsConfigValue, + SettingsFieldConfig, + SettingsConfigType + }; +} + +declare global { + interface Window { + idxThemeStyle?: number; + idxCodeBlock?: number; + } +} diff --git a/tools/ui/src/app.html b/tools/ui/src/app.html new file mode 100644 index 000000000..1391f8848 --- /dev/null +++ b/tools/ui/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
          %sveltekit.body%
          + + diff --git a/tools/ui/src/lib/actions/fade-in-view.svelte.ts b/tools/ui/src/lib/actions/fade-in-view.svelte.ts new file mode 100644 index 000000000..d93044805 --- /dev/null +++ b/tools/ui/src/lib/actions/fade-in-view.svelte.ts @@ -0,0 +1,47 @@ +import { isElementInViewport } from '$lib/utils/viewport'; + +/** + * Svelte action that fades in an element when it enters the viewport. + * Uses IntersectionObserver for efficient viewport detection. + * + * If skipIfVisible is set and the element is already visible in the viewport + * when the action attaches (e.g. a markdown block promoted from unstable + * during streaming), the fade is skipped entirely to avoid a flash. + */ +export function fadeInView( + node: HTMLElement, + options: { duration?: number; y?: number; skipIfVisible?: boolean } = {} +) { + const { duration = 300, y = 0, skipIfVisible = false } = options; + + if (skipIfVisible && isElementInViewport(node)) { + return; + } + + node.style.opacity = '0'; + node.style.transform = `translateY(${y}px)`; + node.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`; + + $effect(() => { + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + requestAnimationFrame(() => { + node.style.opacity = '1'; + node.style.transform = 'translateY(0)'; + }); + observer.disconnect(); + } + } + }, + { threshold: 0.05 } + ); + + observer.observe(node); + + return () => { + observer.disconnect(); + }; + }); +} diff --git a/tools/ui/src/lib/components/app/SKILL.md b/tools/ui/src/lib/components/app/SKILL.md new file mode 100644 index 000000000..7453954ab --- /dev/null +++ b/tools/ui/src/lib/components/app/SKILL.md @@ -0,0 +1,11 @@ +--- +name: app +description: Opinionated app components building on top of ./ui primitives +--- + +- Can include business logic and state management +- Can include data fetching and caching logic +- Should use original spelling for HTML-native events and `camelCase` for custom events +- Props and markup attributes should be listed alphabetically +- Use JS Objects and Arrays for CSS classes and styles when they are dynamic +- Whenever there can be repetition in the component's markup, if it's too small to be decoupled as a separate component — use Svelte 5's `{#snippet}` + `{@render}` diff --git a/tools/ui/src/lib/components/app/actions/ActionIcon.svelte b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte new file mode 100644 index 000000000..849b83b19 --- /dev/null +++ b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte @@ -0,0 +1,60 @@ + + + + + + + + +

          {tooltip}

          +
          +
          diff --git a/tools/ui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte b/tools/ui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte new file mode 100644 index 000000000..999f0cba9 --- /dev/null +++ b/tools/ui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte @@ -0,0 +1,17 @@ + + + canCopy && copyToClipboard(text)} +/> diff --git a/tools/ui/src/lib/components/app/actions/index.ts b/tools/ui/src/lib/components/app/actions/index.ts new file mode 100644 index 000000000..4bb2a58d6 --- /dev/null +++ b/tools/ui/src/lib/components/app/actions/index.ts @@ -0,0 +1,13 @@ +/** + * + * ACTIONS + * + * Small interactive components for user actions. + * + */ + +/** Styled icon button for action triggers with tooltip. */ +export { default as ActionIcon } from './ActionIcon.svelte'; + +/** Copy-to-clipboard icon button with clipboard logic. */ +export { default as ActionIconCopyToClipboard } from './ActionIconCopyToClipboard.svelte'; diff --git a/tools/ui/src/lib/components/app/badges/BadgeInfo.svelte b/tools/ui/src/lib/components/app/badges/BadgeInfo.svelte new file mode 100644 index 000000000..25986082b --- /dev/null +++ b/tools/ui/src/lib/components/app/badges/BadgeInfo.svelte @@ -0,0 +1,26 @@ + + + diff --git a/tools/ui/src/lib/components/app/badges/BadgesModality.svelte b/tools/ui/src/lib/components/app/badges/BadgesModality.svelte new file mode 100644 index 000000000..841f1dd9f --- /dev/null +++ b/tools/ui/src/lib/components/app/badges/BadgesModality.svelte @@ -0,0 +1,32 @@ + + +{#each modalities as modality (modality)} + {#if modality === ModelModality.VISION || modality === ModelModality.AUDIO} + + {#if modality === ModelModality.VISION} + + + Vision + {:else} + + + Audio + {/if} + + {/if} +{/each} diff --git a/tools/ui/src/lib/components/app/badges/index.ts b/tools/ui/src/lib/components/app/badges/index.ts new file mode 100644 index 000000000..f8098056f --- /dev/null +++ b/tools/ui/src/lib/components/app/badges/index.ts @@ -0,0 +1,13 @@ +/** + * + * BADGES & INDICATORS + * + * Small visual indicators for status and metadata. + * + */ + +/** Generic info badge with optional tooltip and click handler. */ +export { default as BadgeInfo } from './BadgeInfo.svelte'; + +/** Badge indicating model modality (vision, audio, tools). */ +export { default as BadgesModality } from './BadgesModality.svelte'; diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte new file mode 100644 index 000000000..e74bd8456 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte @@ -0,0 +1,119 @@ + + +{#snippet attachmentitem(item: ChatAttachmentDisplayItem)} + openPreview(i, event)} + {readonly} + /> +{/snippet} + +{#if displayItems.length > 0} +
          + {#if limitToSingleRow} + + {#each displayItems as item (item.id)} + {@render attachmentitem(item)} + {/each} + + {:else} +
          + {#each displayItems as item (item.id)} + {@render attachmentitem(item)} + {/each} +
          + {/if} +
          +{/if} + + + +{#if mcpResourcePreviewExtra} + +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte new file mode 100644 index 000000000..143621cd9 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte @@ -0,0 +1,132 @@ + + +{#if isMcpPrompt(item)} + {@const mcpPrompt = + item.attachment?.type === AttachmentType.MCP_PROMPT + ? (item.attachment as DatabaseMessageExtraMcpPrompt) + : item.uploadedFile?.mcpPrompt + ? { + type: AttachmentType.MCP_PROMPT as const, + name: item.name, + serverName: item.uploadedFile.mcpPrompt.serverName, + promptName: item.uploadedFile.mcpPrompt.promptName, + content: item.textContent ?? '', + arguments: item.uploadedFile.mcpPrompt.arguments + } + : null} + {#if mcpPrompt} + onFileRemove(item.id) : undefined} + /> + {/if} +{:else if isMcpResource(item)} + {@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource} + + onMcpResourcePreview?.(mcpResource)} + /> +{:else if item.isImage && item.preview} + onPreview?.(item)} + /> +{:else if isPdfFile(item.attachment, item.uploadedFile)} + onPreview?.(item)} + /> +{:else} + onPreview?.(item)} + /> +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpPrompt.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpPrompt.svelte new file mode 100644 index 000000000..636e93f22 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpPrompt.svelte @@ -0,0 +1,41 @@ + + +
          + + + {#if !readonly && onRemove} +
          + onRemove?.()} /> +
          + {/if} +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpResource.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpResource.svelte new file mode 100644 index 000000000..6e1f639fa --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpResource.svelte @@ -0,0 +1,89 @@ + + + + + + + + +
          + {#if favicon} + {attachment.resource.serverName} { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + src={favicon} + /> + {/if} + + + {serverName} + +
          +
          +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailFile.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailFile.svelte new file mode 100644 index 000000000..3eeace42f --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailFile.svelte @@ -0,0 +1,174 @@ + + +{#snippet textPreview(content: string)} +
          +
          + {getPreviewText(content)} +
          + + {#if content.length > 150} +
          + {/if} +
          +{/snippet} + +{#snippet removeButton()} +
          + onRemove?.(id)} /> +
          +{/snippet} + +{#snippet fileIcon()} +
          + {fileTypeLabel} +
          +{/snippet} + +{#snippet info(text: string | undefined)} + {#if text} + {text} + {/if} +{/snippet} + +{#if isTextWithContent || isPdfWithContent} + +{:else} + +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte new file mode 100644 index 000000000..b78a65916 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte @@ -0,0 +1,65 @@ + + +{#snippet image()} + {name} +{/snippet} + +
          + {#if onclick} + + {:else} + {@render image()} + {/if} + + {#if !readonly} +
          + onRemove?.(id)} + stopPropagationOnClick + tooltip="Remove" + /> +
          + {/if} +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview.svelte new file mode 100644 index 000000000..ca81e5443 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview.svelte @@ -0,0 +1,190 @@ + + +
          +
          + 1} /> + +
          + {#if currentItem} + + + + {/if} + + +
          +
          +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItem.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItem.svelte new file mode 100644 index 000000000..3451c89d3 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItem.svelte @@ -0,0 +1,65 @@ + + +{#if currentItem} + {#key currentItem.id} + {#if isPdf} + + {:else if isImage} + + {:else if isText && displayTextContent} + + {:else if isAudio} + + {:else if isUnavailable} + + {/if} + {/key} +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemAudio.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemAudio.svelte new file mode 100644 index 000000000..06e1f5928 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemAudio.svelte @@ -0,0 +1,26 @@ + + +
          +
          + + + {#if audioSrc} + + {:else} +

          Audio preview not available

          + {/if} + +

          {currentItem?.name || 'Audio'}

          +
          +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemImage.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemImage.svelte new file mode 100644 index 000000000..070ff8230 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemImage.svelte @@ -0,0 +1,18 @@ + + +{#if displayPreview} +
          + {currentItem?.name +
          +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte new file mode 100644 index 000000000..750532a62 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte @@ -0,0 +1,174 @@ + + +
          + + + +
          + +{#if !hasVisionModality && activeModelId && currentItem} + + + Preview only + + + The selected model does not support vision. Only the extracted + + + (pdfViewMode = PdfViewMode.TEXT)} + > + text + + will be sent to the model. + + + +{/if} + +{#if pdfImagesLoading} +
          +
          +
          +

          Converting PDF to images...

          +
          +
          +{:else if pdfImagesError} +
          +
          + +

          Failed to load PDF images

          +

          {pdfImagesError}

          +
          +
          +{:else if pdfImages.length > 0} + {#each pdfImages as image, index (image)} +

          Page {index + 1}

          + PDF Page {index + 1} +
          + {/each} +{:else} +
          +
          + +

          No PDF pages available

          +
          +
          +{/if} + +{#if pdfViewMode === PdfViewMode.TEXT && displayTextContent} +
          + +
          +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemText.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemText.svelte new file mode 100644 index 000000000..5977523ac --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemText.svelte @@ -0,0 +1,21 @@ + + +{#if displayTextContent} +
          + +
          +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemUnavailable.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemUnavailable.svelte new file mode 100644 index 000000000..d3002a939 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemUnavailable.svelte @@ -0,0 +1,17 @@ + + +
          +
          + + +

          Preview not available for this file type

          +
          +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte new file mode 100644 index 000000000..d27d54a4b --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte @@ -0,0 +1,16 @@ + + +
          +

          {displayName}

          + + {#if fileSize} +

          {fileSize}

          + {/if} +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte new file mode 100644 index 000000000..a57e3145a --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte @@ -0,0 +1,34 @@ + + +{#if show} + + + +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte new file mode 100644 index 000000000..4c3bd7807 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte @@ -0,0 +1,63 @@ + + +{#if items.length > 1} +
          + + {#each items as item, index (item.id)} + + {/each} + +
          +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte new file mode 100644 index 000000000..46ac82334 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -0,0 +1,570 @@ + + + + +
          { + event.preventDefault(); + + if (!canSubmit || disabled || hasLoadingAttachments) return; + + onSubmit?.(); + }} +> + + +
          + + +
          + { + handleInput(); + onValueChange?.(value); + }} + {disabled} + {placeholder} + /> + + {#if mcpHasResourceAttachments()} + { + preSelectedResourceUri = uri; + isResourceDialogOpen = true; + }} + /> + {/if} + + onSystemPromptClick?.({ message: value, files: uploadedFiles })} + onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined} + onMcpResourcesClick={() => (isResourceDialogOpen = true)} + /> +
          +
          + + + { + mcpStore.attachResource(resource.uri); + }} + onOpenChange={(newOpen: boolean) => { + if (!newOpen) { + preSelectedResourceUri = undefined; + } + }} +/> diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddButton.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddButton.svelte new file mode 100644 index 000000000..7175888aa --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddButton.svelte @@ -0,0 +1,33 @@ + + + + + + + + +

          {ATTACHMENT_TOOLTIP_TEXT}

          +
          +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte new file mode 100644 index 000000000..e053e6f83 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte @@ -0,0 +1,173 @@ + + +
          + + + {@render trigger({ disabled })} + + + + {#each ATTACHMENT_FILE_ITEMS as item (item.id)} + {@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)} + {#if enabled} + attachmentMenu.callbacks[item.action]()} + > + + + {item.label} + + {:else if item.disabledTooltip} + + + + + + {item.label} + + + + +

          {item.disabledTooltip}

          +
          +
          + {/if} + {/each} + + {#if !attachmentMenu.isItemEnabled('hasVisionModality')} + + + + {@const pdfItem = ATTACHMENT_FILE_ITEMS.find( + (i) => i.id === AttachmentMenuItemId.PDF + )} + {#if pdfItem} + + + {pdfItem.label} + {/if} + + + + +

          PDFs will be converted to text. Image-based PDFs may not work properly.

          +
          +
          + {/if} + + + + {#each ATTACHMENT_EXTRA_ITEMS as item (item.id)} + {#if item.id === AttachmentMenuItemId.SYSTEM_MESSAGE} + + + attachmentMenu.callbacks[item.action]()} + > + + + {item.label} + + + + +

          {attachmentMenu.getSystemMessageTooltip()}

          +
          +
          + {/if} + {/each} + + + + + + {#each ATTACHMENT_MCP_ITEMS as item (item.id)} + {#if attachmentMenu.isItemVisible(item.visibleWhen)} + attachmentMenu.callbacks[item.action]()} + > + + + {item.label} + + {/if} + {/each} +
          +
          +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte new file mode 100644 index 000000000..dd357d6cd --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte @@ -0,0 +1,150 @@ + + + + + + + + MCP Servers + + + + {#if hasMcpServers} + +
          + {#each filteredMcpServers as server (server.id)} + {@const healthState = mcpStore.getHealthCheckState(server.id)} + {@const hasError = healthState.status === HealthCheckStatus.ERROR} + {@const isEnabledForChat = isServerEnabledForChat(server.id)} + {@const displayName = getServerLabel(server)} + {@const faviconUrl = mcpStore.getServerFavicon(server.id)} + + + {/each} +
          + + {#snippet footer()} + + + + Manage MCP Servers + + {/snippet} +
          + {:else} +
          + No MCP servers configured +
          + + + + + + + Add MCP Servers + + {/if} +
          +
          +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte new file mode 100644 index 000000000..4713ec477 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte @@ -0,0 +1,187 @@ + + +
          + + {@render trigger({ disabled, onclick: () => (sheetOpen = true) })} + + + + + Add to chat + + + Add files, system prompt or configure MCP servers + + + +
          + {#each ATTACHMENT_FILE_ITEMS as item (item.id)} + {@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)} + {#if enabled} + + {:else if item.disabledTooltip} + + + + + + +

          {item.disabledTooltip}

          +
          +
          + {/if} + {/each} + + {#if !attachmentMenu.isItemEnabled('hasVisionModality')} + {@const pdfItem = ATTACHMENT_FILE_ITEMS.find((i) => i.id === AttachmentMenuItemId.PDF)} + {#if pdfItem} + + + + + + +

          PDFs will be converted to text. Image-based PDFs may not work properly.

          +
          +
          + {/if} + {/if} + + {#each ATTACHMENT_EXTRA_ITEMS as item (item.id)} + {#if item.id === AttachmentMenuItemId.SYSTEM_MESSAGE} + + + + + + +

          {attachmentMenu.getSystemMessageTooltip()}

          +
          +
          + {/if} + {/each} + +
          + + + + + MCP Servers + + + + + + Tools + + + {#each ATTACHMENT_MCP_ITEMS as item (item.id)} + {#if attachmentMenu.isItemVisible(item.visibleWhen)} + + {/if} + {/each} +
          +
          +
          +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte new file mode 100644 index 000000000..b11467da8 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte @@ -0,0 +1,150 @@ + + + open && toolsPanel.handleOpen()}> + + + + Tools + + + + {#if toolsPanel.totalToolCount === 0} + {#if toolsStore.loading} +
          + + + Loading tools... +
          + {:else if toolsStore.isToolsEndpointUnreachable} +
          + + + + + Run llama-server with {CLI_FLAGS.TOOLS} flag to enable + + Built-in Tools. + + + + + + + + {hasMcpServersAvailable ? 'Enable' : 'Add'} MCP Server(s) to access + + MCP Tools. + + +
          + {:else if toolsStore.error} +
          Failed to load tools
          + {:else if toolsPanel.noToolsInfoMessage} +
          + + + {toolsPanel.noToolsInfoMessage} +
          + {:else} +
          No tools available
          + {/if} + {:else} +
          + {#each toolsPanel.activeGroups as group (group.label)} + {@const isExpanded = toolsPanel.expandedGroups.has(group.label)} + {@const { checked, indeterminate } = toolsPanel.getGroupCheckedState(group)} + {@const favicon = toolsPanel.getFavicon(group)} + + toolsPanel.toggleGroupExpanded(group.label)} + > +
          + + {#if isExpanded} + + {:else} + + {/if} + + + {#if favicon} + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + {/if} + + {group.label} + + + + {toolsPanel.getEnabledToolCount(group)}/{group.tools.length} + + + + + + toolsStore.toggleGroup(group)} + class="mr-2 h-4 w-4 shrink-0" + /> + + + +

          + {checked ? 'Disable' : 'Enable'} + {group.tools.length} tool{group.tools.length !== 1 ? 's' : ''} +

          +
          +
          +
          + + +
          + {#each group.tools as tool (tool.function.name)} + + {/each} +
          +
          +
          + {/each} +
          + {/if} +
          +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte new file mode 100644 index 000000000..8cfd7d809 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte @@ -0,0 +1,68 @@ + + +{#if isMobile.current} + + {#snippet trigger({ disabled, onclick })} + + {/snippet} + +{:else} + + {#snippet trigger()} + + {/snippet} + +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte new file mode 100644 index 000000000..bdd84a481 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte @@ -0,0 +1,160 @@ + + +{#if isMobile.current} + +{:else} + +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte new file mode 100644 index 000000000..f1b084906 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte @@ -0,0 +1,52 @@ + + +
          + + + + + + {#if !hasAudioModality} + +

          Current model does not support audio

          +
          + {/if} +
          +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte new file mode 100644 index 000000000..8774bf63a --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte @@ -0,0 +1,46 @@ + + +{#snippet submitButton(props = {})} + +{/snippet} + +{#if tooltipLabel} + + + {@render submitButton()} + + + +

          {tooltipLabel}

          +
          +
          +{:else} + {@render submitButton()} +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte new file mode 100644 index 000000000..3945155ff --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte @@ -0,0 +1,146 @@ + + +
          + {#if showAddButton} +
          + goto(ROUTES.MCP_SERVERS)} + /> +
          + {/if} + + {#if showModelSelector} + + {/if} + + {#if isLoading && !canSubmit} + + {:else if shouldShowRecordButton} + + {:else} + + {/if} +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte new file mode 100644 index 000000000..395ecb201 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte @@ -0,0 +1,31 @@ + + + diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormMcpResourcesList.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormMcpResourcesList.svelte new file mode 100644 index 000000000..36c82224a --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormMcpResourcesList.svelte @@ -0,0 +1,44 @@ + + +{#if hasAttachments} +
          + + {#each attachments as attachment, i (attachment.id)} + handleResourceClick(attachment.resource.uri)} + /> + {/each} + +
          +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerItemHeader.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerItemHeader.svelte new file mode 100644 index 000000000..11ca52049 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerItemHeader.svelte @@ -0,0 +1,55 @@ + + +
          +
          + {#if faviconUrl} + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + {/if} + + {serverLabel} +
          + +
          + + {title} + + + {#if titleExtra} + {@render titleExtra()} + {/if} +
          + + {#if description} +

          + {description} +

          + {/if} + + {#if subtitle} + {@render subtitle()} + {/if} +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte new file mode 100644 index 000000000..6647928b2 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte @@ -0,0 +1,81 @@ + + + + {#if showSearchInput} +
          + +
          + {/if} + +
          + {#if isLoading} + {#if skeleton} + {@render skeleton()} + {/if} + {:else if items.length === 0} +
          {emptyMessage}
          + {:else} + {#each items as itemData, index (itemKey(itemData, index))} + {@render item(itemData, index, index === selectedIndex)} + {/each} + {/if} +
          + + {#if footer} + {@render footer()} + {/if} +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte new file mode 100644 index 000000000..4d82c6b58 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte @@ -0,0 +1,23 @@ + + + diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte new file mode 100644 index 000000000..5a2ab26fc --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte @@ -0,0 +1,30 @@ + + +
          +
          + +
          +
          +
          +
          + + +
          +
          + + {#if showBadge} +
          + {/if} +
          + + +
          +
          +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte new file mode 100644 index 000000000..c43a002e6 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte @@ -0,0 +1,50 @@ + + + { + if (!open) { + onClose?.(); + } + }} +> + + + event.preventDefault()} + > + {@render children()} + + diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte new file mode 100644 index 000000000..567fdac47 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte @@ -0,0 +1,435 @@ + + + + {#if selectedPrompt} + {@const prompt = selectedPrompt} + {@const server = serverSettingsMap.get(prompt.serverName)} + {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName} + +
          + + {#snippet titleExtra()} + {#if prompt.arguments?.length} + + {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''} + + {/if} + {/snippet} + + + +
          + {:else} + prompt.serverName + ':' + prompt.name} + > + {#snippet item(prompt, index, isSelected)} + {@const server = serverSettingsMap.get(prompt.serverName)} + {@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName} + + handlePromptClick(prompt)} + > + + {#snippet titleExtra()} + {#if prompt.arguments?.length} + + {prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''} + + {/if} + {/snippet} + + + {/snippet} + + {#snippet skeleton()} + + {/snippet} + + {/if} +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentForm.svelte new file mode 100644 index 000000000..92572b895 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentForm.svelte @@ -0,0 +1,74 @@ + + +
          + {#each prompt.arguments ?? [] as arg (arg.name)} + onArgInput(arg.name, value)} + onKeydown={(e) => onArgKeydown(e, arg.name)} + onBlur={() => onArgBlur(arg.name)} + onFocus={() => onArgFocus(arg.name)} + onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)} + /> + {/each} + + {#if promptError} + + {/if} + +
          + + + +
          + diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentInput.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentInput.svelte new file mode 100644 index 000000000..638d10eef --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentInput.svelte @@ -0,0 +1,84 @@ + + +
          + + + onInput(e.currentTarget.value)} + onkeydown={onKeydown} + onblur={onBlur} + onfocus={onFocus} + placeholder={argument.description || argument.name} + required={argument.required} + autocomplete="off" + /> + + {#if isAutocompleteActive && suggestions.length > 0} +
          + {#each suggestions as suggestion, i (suggestion)} + + {/each} +
          + {/if} +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte new file mode 100644 index 000000000..1125ae8ec --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte @@ -0,0 +1,237 @@ + + + + resource.serverName + ':' + resource.uri} + > + {#snippet item(resource, index, isSelected)} + {@const server = serverSettingsMap.get(resource.serverName)} + {@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName} + + handleResourceClick(resource)} + > + + {#snippet titleExtra()} + {#if isResourceAttached(resource.uri)} + + attached + + {/if} + {/snippet} + + {#snippet subtitle()} +

          + {resource.uri} +

          + {/snippet} +
          +
          + {/snippet} + + {#snippet skeleton()} + + {/snippet} + + {#snippet footer()} + {#if onBrowse && resources.length > 3} + + {/if} + {/snippet} +
          +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte new file mode 100644 index 000000000..7c5dc85b2 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte @@ -0,0 +1,75 @@ + + + + + diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte new file mode 100644 index 000000000..72e62f319 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte @@ -0,0 +1,68 @@ + + +
          + +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte new file mode 100644 index 000000000..4d0b302d2 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte @@ -0,0 +1,395 @@ + + +
          + {#if message.role === MessageRole.SYSTEM} + + {:else if mcpPromptExtra} + + {:else if message.role === MessageRole.USER} + + {:else} + + {/if} +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte new file mode 100644 index 000000000..b4d69b932 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte @@ -0,0 +1,390 @@ + + +
          + {#if showProcessingInfoTop} +
          +
          + + {processingState.getPromptProgressText() ?? + processingState.getProcessingMessage() ?? + 'Processing...'} + +
          +
          + {/if} + + {#if editCtx.isEditing} + + {:else if message.role === MessageRole.ASSISTANT} + {#if showRawOutput} +
          {rawOutputContent || ''}
          + {:else} + + {/if} + {:else} +
          + {messageContent} +
          + {/if} + + {#if showProcessingInfoBottom} +
          +
          + + {processingState.getPromptProgressText() ?? + processingState.getProcessingMessage() ?? + 'Processing...'} + +
          +
          + {/if} + +
          + {#if displayedModel} +
          + {#if isRouter} + { + const status = modelsStore.getModelStatus(modelId); + + if (status !== ServerModelStatus.LOADED) { + await modelsStore.loadModel(modelId); + } + + onRegenerate(modelName); + return true; + }} + /> + {:else} + + {/if} + + {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms} + {@const agentic = message.timings.agentic} + + {:else if isLoading() && currentConfig.showMessageStats} + {@const liveStats = processingState.getLiveProcessingStats()} + {@const genStats = processingState.getLiveGenerationStats()} + {@const promptProgress = processingState.processingState?.promptProgress} + {@const isStillProcessingPrompt = + promptProgress && promptProgress.processed < promptProgress.total} + + {#if liveStats || genStats} + + {/if} + {/if} +
          + {/if} +
          + + {#if message.timestamp && !editCtx.isEditing} + (showRawOutput = enabled)} + /> + {/if} +
          + + diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte new file mode 100644 index 000000000..2dcb36baf --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte @@ -0,0 +1,83 @@ + + +
          + {#if editCtx.isEditing} + + {:else} + + + {#if message.timestamp} +
          + +
          + {/if} + {/if} +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPromptContent.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPromptContent.svelte new file mode 100644 index 000000000..3d5dec3b6 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPromptContent.svelte @@ -0,0 +1,197 @@ + + +
          +
          +
          + + + {#if serverFavicon} + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + {/if} + + + + {serverDisplayName} + + + + +
          + + {#if showArgBadges} +
          + {#each argumentEntries as [key, value] (key)} + + + + (hoveredArgKey = key)} + onmouseleave={() => (hoveredArgKey = null)} + > + {key} + + + + + {value} + + + {/each} +
          + {/if} +
          + + {#if loadError} + +
          + {loadError} +
          +
          + {:else if isLoading} + +
          +
          +
          + +
          + +
          +
          +
          +
          + {:else if hasContent} + +
          + + + + {#each contentParts as part, i (i)}{#if part.argKey} (hoveredArgKey = part.argKey)} + onmouseleave={() => (hoveredArgKey = null)}>{part.text}{:else}{part.text}{/if}{/each} +
          +
          + {/if} +
          diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte new file mode 100644 index 000000000..9d3d07a27 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte @@ -0,0 +1,232 @@ + + +
          + {#if editCtx.isEditing} +
          + + +
          + + + +
          +
          + {:else} + {#if message.content.trim()} +
          + +
          + {/if} +
          + + {#if isExpanded && showExpandButton} +
          + +
          + {/if} + + +
      • + {/if} + + {#if message.timestamp} +
        + +
        + {/if} + {/if} + diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte new file mode 100644 index 000000000..96ec1ddfd --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte @@ -0,0 +1,83 @@ + + +
        + {#if editCtx.isEditing} + + {:else} + + + {#if message.timestamp} +
        + +
        + {/if} + {/if} +
        diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte new file mode 100644 index 000000000..dabb337dd --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte @@ -0,0 +1,76 @@ + + +{#if attachments && attachments.length > 0} +
        + +
        +{/if} + +{#if content.trim()} + + {#if renderMarkdown && currentConfig.renderUserContentAsMarkdown} +
        + +
        + {:else} + + {content} + + {/if} +
        +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte new file mode 100644 index 000000000..4be582b39 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte @@ -0,0 +1,69 @@ + + +
        + {#if editCtx.isEditing} + + {:else} + + +
        +
        +
        +
        + + + +
        +
        +
        +
        + {/if} +
        diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte new file mode 100644 index 000000000..254031979 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte @@ -0,0 +1,23 @@ + + +
        +
        + + + {@render message()} + +
        +
        + {@render actions()} +
        +
        diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardContinueRequest.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardContinueRequest.svelte new file mode 100644 index 000000000..bbb1f0ac2 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardContinueRequest.svelte @@ -0,0 +1,30 @@ + + + + {#snippet message()} + Agentic turn limit reached. Continue? + {/snippet} + + {#snippet actions()} + + + + {/snippet} + diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte new file mode 100644 index 000000000..e466c84ee --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte @@ -0,0 +1,88 @@ + + + + {#snippet message()} + Allow use of + + {toolName} + + {#if serverLabel} + from {serverLabel} + {/if} + + ? + {/snippet} + + {#snippet actions()} + + + + + + + + + + + + + onDecision(ToolPermissionDecision.ALWAYS)}> + Always allow
        {toolName}
        + tool +
        + {#if serverLabel} + onDecision(ToolPermissionDecision.ALWAYS_SERVER)}> + Always allow all tools from {serverLabel} + + {:else} + {@const source = toolsStore.getToolSource(toolName)} + {@const providerName = + source === ToolSource.BUILTIN + ? TOOL_SERVER_LABELS[ToolSource.BUILTIN] + : source === ToolSource.CUSTOM + ? TOOL_SERVER_LABELS[ToolSource.CUSTOM] + : 'MCP Tools'} + onDecision(ToolPermissionDecision.ALWAYS_SERVER)}> + Approve all tools from {providerName} + + {/if} +
        +
        + + + {/snippet} +
        diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte new file mode 100644 index 000000000..503a2d086 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte @@ -0,0 +1,184 @@ + + +
        +
        + {#if siblingInfo && siblingInfo.totalSiblings > 1} + + {/if} + +
        + + + {#if onEdit} + + {/if} + + {#if role === MessageRole.ASSISTANT && onRegenerate} + onRegenerate()} /> + {/if} + + {#if role === MessageRole.ASSISTANT && onContinue} + + {/if} + + {#if onForkConversation} + + {/if} + + +
        +
        + + {#if showRawOutputSwitch} +
        + Show raw output + onRawOutputToggle?.(checked)} + /> +
        + {/if} +
        + + 1 + ? `This will delete ${deletionInfo.totalCount} messages including: ${deletionInfo.userMessages} user message${deletionInfo.userMessages > 1 ? 's' : ''} and ${deletionInfo.assistantMessages} assistant response${deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.` + : 'Are you sure you want to delete this message? This action cannot be undone.'} + confirmText={deletionInfo && deletionInfo.totalCount > 1 + ? `Delete ${deletionInfo.totalCount} Messages` + : 'Delete'} + cancelText="Cancel" + variant="destructive" + icon={Trash2} + onConfirm={handleConfirmDelete} + onCancel={() => onShowDeleteDialogChange(false)} +/> + + (showForkDialog = false)} +> +
        +
        + + + +
        + +
        + { + forkIncludeAttachments = checked === true; + }} + /> + + +
        +
        +
        diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIconsBranchingControls.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIconsBranchingControls.svelte new file mode 100644 index 000000000..465dcab73 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIconsBranchingControls.svelte @@ -0,0 +1,49 @@ + + +{#if siblingInfo && siblingInfo.totalSiblings > 1} + +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte new file mode 100644 index 000000000..3a9cc7e93 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte @@ -0,0 +1,415 @@ + + +{#snippet renderSection(section: (typeof sectionsParsed)[number], index: number)} + {#if section.type === AgenticSectionType.TEXT} +
        + +
        + {:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING} + {@const streamingIcon = isStreaming ? Loader2 : Loader2} + {@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} + + toggleExpanded(index, section)} + > +
        +
        + Arguments: + + {#if isStreaming} + + {/if} +
        + {#if section.toolArgs} + + {:else if isStreaming} +
        + Receiving arguments... +
        + {:else} +
        + Response was truncated +
        + {/if} +
        +
        + {:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING} + {@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING} + {@const toolIcon = isPending ? Loader2 : Wrench} + {@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} + + toggleExpanded(index, section)} + > + {#if section.toolArgs && section.toolArgs !== '{}'} +
        +
        Arguments:
        + + +
        + {/if} + +
        +
        + Result: + + {#if isPending} + + {/if} +
        + {#if isPending} +
        + Waiting for result... +
        + {:else if section.toolResult} +
        + {#each section.parsedLines as line, i (i)} +
        + {line.text} +
        + {#if line.image} + {line.image.name} + {/if} + {/each} +
        + {:else} +
        No output
        + {/if} +
        +
        + {:else if section.type === AgenticSectionType.REASONING} + toggleExpanded(index, section)} + > +
        +
        + {section.content} +
        +
        +
        + {:else if section.type === AgenticSectionType.REASONING_PENDING} + {@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'} + {@const reasoningSubtitle = isStreaming ? '' : 'incomplete'} + + toggleExpanded(index, section)} + > +
        +
        + {section.content} +
        +
        +
        + {/if} +{/snippet} + +
        + {#if highlightTurns && turnGroups.length > 1} + {#each turnGroups as turn, turnIndex (turnIndex)} + {@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]} +
        + Turn {turnIndex + 1} + {#each turn.sections as section, sIdx (turn.flatIndices[sIdx])} + {@render renderSection(section, turn.flatIndices[sIdx])} + {/each} + {#if turnStats} +
        + 0 + ? buildTurnAgenticTimings(turnStats) + : undefined} + initialView={ChatMessageStatsView.GENERATION} + hideSummary + /> +
        + {/if} +
        + {/each} + {:else} + {#each sectionsParsed as section, index (index)} + {@render renderSection(section, index)} + {/each} + {/if} + + {#if pendingPermission && !permissionDismissed} + + {/if} + + {#if pendingContinue && !continueDismissed} + + {/if} +
        + + diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte new file mode 100644 index 000000000..962f2a285 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte @@ -0,0 +1,154 @@ + + + + +
        + +
        + +
        + {#if isUserMessage && editCtx.showSaveOnlyOption} +
        + + + +
        + {:else if isAssistantMessage} +
        + + + +
        + {:else} +
        + {/if} + + +
        + + (showDiscardDialog = false)} +/> diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte new file mode 100644 index 000000000..34362e026 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte @@ -0,0 +1,303 @@ + + +
        +
        + {#if hasPromptStats || isLive} + + + + + + +

        Reading (prompt processing)

        +
        +
        + {/if} + + + + + + +

        + {isGenerationDisabled + ? 'Generation (waiting for tokens...)' + : 'Generation (token output)'} +

        +
        +
        + + {#if hasAgenticStats} + + + + + + +

        Tool calls

        +
        +
        + + {#if !hideSummary} + + + + + + +

        Agentic summary

        +
        +
        + {/if} + {/if} +
        + +
        + {#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats} + + + + + + {:else if activeView === ChatMessageStatsView.TOOLS && hasAgenticStats} + + + + + + {:else if activeView === ChatMessageStatsView.SUMMARY && hasAgenticStats} + + + + + + {:else if hasPromptStats} + + + + + + {/if} +
        +
        diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte new file mode 100644 index 000000000..eea7da7b2 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte @@ -0,0 +1,44 @@ + + +{#if tooltipLabel} + + + + {#snippet icon()} + + {/snippet} + + {value} + + + +

        {tooltipLabel}

        +
        +
        +{:else} + + {#snippet icon()} + + {/snippet} + + {value} + +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte new file mode 100644 index 000000000..281e6ad0c --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte @@ -0,0 +1,294 @@ + + +
        + {#each displayMessages as { message, toolMessages, isLastAssistantMessage, siblingInfo } (message.id)} + + {/each} + + {#if activeConversation() && agenticPendingSteeringMessageContent(activeConversation()!.id)} + {@const convId = activeConversation()!.id} + {@const pendingContent = agenticPendingSteeringMessageContent(convId)} + + {#if pendingContent} + chatStore.abortCurrentFlow(convId)} + onEdit={(newContent, extras) => agenticInjectSteeringMessage(convId, newContent, extras)} + onDelete={() => agenticClearSteeringMessage(convId)} + /> + {/if} + {:else if activeConversation() && chatPendingMessageContent(activeConversation()!.id)} + {@const convId = activeConversation()!.id} + {@const pendingContent = chatPendingMessageContent(convId)} + + {#if pendingContent} + chatStore.abortCurrentFlow(convId)} + onEdit={(newContent, extras) => chatInjectPendingMessage(convId, newContent, extras)} + onDelete={() => chatClearPendingMessage(convId)} + /> + {/if} + {/if} +
        diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte new file mode 100644 index 000000000..1351ed0a7 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte @@ -0,0 +1,482 @@ + + +{#if isDragOver} + +{/if} + + + +{#if isServerLoading} + +{:else} +
        +
        + {#if !isEmpty} + { + autoScroll.enable(); + if (!autoScroll.userScrolledUp) { + autoScroll.scrollToBottom(); + } + }} + onMessagesReady={handleMessagesReady} + /> + {/if} + +
        + {#if isEmpty} +
        +

        Hello there

        + +

        + {serverStore.props?.modalities?.audio + ? 'Record audio, type a message ' + : 'Type a message'} or upload files to get started +

        +
        + {/if} + + {#if page.params.id} + + {/if} + + {#if hasPropsError} +
        + + + + Server unavailable + + + {serverError()} + +
        + {/if} + +
        + chatStore.stopGeneration()} + onSystemPromptAdd={handleSystemPromptAdd} + bind:uploadedFiles + /> +
        +
        +
        +
        +{/if} + + + + (showDeleteDialog = false)} +/> + + { + if (!open) { + emptyFileNames = []; + } + }} +/> + + diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte new file mode 100644 index 000000000..ab4adb2c2 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte @@ -0,0 +1,17 @@ + + +
        +
        + + +

        Attach a file

        + +

        Drop your files here to upload

        +
        +
        diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte new file mode 100644 index 000000000..aa1c0536d --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte @@ -0,0 +1,126 @@ + + +
        + +
        diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte new file mode 100644 index 000000000..b5979db13 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte @@ -0,0 +1,120 @@ + + +
        +
        + {#each processingDetails as detail (detail)} + {detail} + {/each} +
        +
        + + diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts new file mode 100644 index 000000000..5f6597980 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/index.ts @@ -0,0 +1,669 @@ +/** + * + * ATTACHMENTS + * + * Components for displaying and managing different attachment types in chat messages. + * Supports two operational modes: + * - **Readonly mode**: For displaying stored attachments in sent messages (DatabaseMessageExtra[]) + * - **Editable mode**: For managing pending uploads in the input form (ChatUploadedFile[]) + * + * The attachment system uses `getAttachmentDisplayItems()` utility to normalize both + * data sources into a unified display format, enabling consistent rendering regardless + * of the attachment origin. + * + */ + +/** + * **ChatAttachmentsList** - Unified display for file attachments in chat + * + * Central component for rendering file attachments in both ChatMessage (readonly) + * and ChatForm (editable) contexts. + * + * **Architecture:** + * - Delegates rendering to specialized thumbnail components based on attachment type + * - Manages scroll state and navigation arrows for horizontal overflow + * - Integrates with DialogChatAttachmentsPreview for full-size gallery/single viewing + * - Validates vision modality support via `activeModelId` prop + * + * **Features:** + * - Horizontal scroll with smooth navigation arrows + * - Image thumbnails with lazy loading and error fallback + * - File type icons for non-image files (PDF, text, audio, etc.) + * - MCP prompt attachments with expandable content preview + * - Click-to-preview with full-size dialog and download option + * - "View All" button when `limitToSingleRow` is enabled and content overflows + * - Vision modality validation to warn about unsupported image uploads + * - Customizable thumbnail dimensions via `imageHeight`/`imageWidth` props + * + * @example + * ```svelte + * + * + * + * + * removeFile(id)} + * limitToSingleRow + * activeModelId={selectedModel} + * /> + * ``` + */ +export { default as ChatAttachmentsList } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte'; + +/** + * Renders a single attachment item based on its type (image, file, MCP prompt, or MCP resource). + * Delegates to specialized sub-components: ChatAttachmentsListItemThumbnailImage, ChatAttachmentsListItemThumbnailFile, + * ChatAttachmentsListItemMcpPrompt, or ChatAttachmentsListItemMcpResource. + */ +export { default as ChatAttachmentsListItem } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte'; + +/** + * Displays MCP Prompt attachment with expandable content preview. + * Shows server name, prompt name, and allows expanding to view full prompt arguments + * and content. Used when user selects a prompt from ChatFormPickerMcpPrompts. + */ +export { default as ChatAttachmentsListItemMcpPrompt } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpPrompt.svelte'; + +/** + * Displays a single MCP Resource attachment with icon, name, and server info. + * Shows loading/error states and supports remove action. + * Used within ChatAttachmentMcpResources for individual resource display. + */ +export { default as ChatAttachmentsListItemMcpResource } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpResource.svelte'; + +/** + * Thumbnail for non-image file attachments. Displays file type icon based on extension, + * file name (truncated), and file size. + * Handles text files, PDFs, audio, and other document types. + */ +export { default as ChatAttachmentsListItemThumbnailFile } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailFile.svelte'; + +/** + * Thumbnail for image attachments with lazy loading and error fallback. + * Displays image preview with configurable dimensions. Falls back to placeholder + * on load error. + */ +export { default as ChatAttachmentsListItemThumbnailImage } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.svelte'; + +/** + * Unified attachment preview component for dialog display. Shows a single file + * preview without carousel, or a gallery/carousel view when multiple items exist. + * Uses ChatAttachmentPreviewSingle internally for each item's content. + */ +export { default as ChatAttachmentsPreview } from './ChatAttachments/ChatAttachmentsPreview.svelte'; +export { default as ChatAttachmentsPreviewNavButtons } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte'; +export { default as ChatAttachmentsPreviewFileInfo } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte'; +export { default as ChatAttachmentsPreviewThumbnailStrip } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte'; +export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItem.svelte'; + +/** + * + * FORM + * + * Components for the chat input area. The form handles user input, file attachments, + * audio recording, and MCP prompts & resources selection. It integrates with multiple stores: + * - `chatStore` for message submission and generation control + * - `modelsStore` for model selection and validation + * - `mcpStore` for MCP prompt browsing and loading + * + * The form exposes a public API for programmatic control from parent components + * (focus, height reset, model selector, validation). + * + */ + +/** + * **ChatForm** - Main chat input component with rich features + * + * The primary input interface for composing and sending chat messages. + * Orchestrates text input, file attachments, audio recording, and MCP prompts. + * Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing. + * + * **Architecture:** + * - Composes ChatFormTextarea, ChatFormActions, and ChatFormPickerMcpPrompts + * - Manages file upload state via `uploadedFiles` bindable prop + * - Integrates with ModelsSelectorDropdown for model selection in router mode + * - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.) + * + * **Input Handling:** + * - IME-safe Enter key handling (waits for composition end) + * - Shift+Enter for newline, Enter for submit + * - Paste handler for files and long text (> {pasteLongTextToFileLen} chars → file conversion) + * - Keyboard shortcut `/` triggers MCP prompt picker + * + * **Features:** + * - Auto-resizing textarea with placeholder + * - File upload via button dropdown (images/text/PDF), drag-drop, or paste + * - Audio recording with WAV conversion (when model supports audio) + * - MCP prompt picker with search and argument forms + * - MCP reource picker with component to list attached resources at the bottom of Chat Form + * - Model selector integration (router mode) + * - Loading state with stop button, disabled state for errors + * + * **Exported API:** + * - `focus()` - Focus the textarea programmatically + * - `resetTextareaHeight()` - Reset textarea to default height after submit + * - `openModelSelector()` - Open model selection dropdown + * - `checkModelSelected(): boolean` - Validate model selection, show error if none + * + * @example + * ```svelte + * + * ``` + */ +export { default as ChatForm } from './ChatForm/ChatForm.svelte'; + +/** + * Wrapper component for the "add to chat" button (Plus icon). + * Exposes a `button` snippet that can be used inside DropdownMenu.Trigger (desktop) + * or Sheet.Root (mobile) to maintain consistent styling while allowing + * platform-specific trigger wrappers. + */ +export { default as ChatFormActionsAdd } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte'; + +/** + * Audio recording button with real-time recording indicator. Records audio + * and converts to WAV format for upload. Only visible when the active model + * supports audio modality and setting for automatic audio input is enabled. Shows recording duration while active. + */ +export { default as ChatFormActionRecord } from './ChatForm/ChatFormActions/ChatFormActionRecord.svelte'; + +/** + * Container for chat form action buttons. Arranges file attachment, audio record, + * and submit/stop buttons in a horizontal layout. Handles conditional visibility + * based on model capabilities and loading state. + */ +export { default as ChatFormActions } from './ChatForm/ChatFormActions/ChatFormActions.svelte'; + +/** + * Submit/stop button with loading state. Shows send icon normally, transforms + * to stop icon during generation. Disabled when input is empty or form is disabled. + * Triggers onSubmit or onStop callbacks based on current state. + */ +export { default as ChatFormActionSubmit } from './ChatForm/ChatFormActions/ChatFormActionSubmit.svelte'; + +/** + * Model selector component for the chat form action bar. Renders either a dropdown + * (desktop) or bottom sheet (mobile) for selecting the conversation model in router mode. + * Exposes an `open` method for programmatically opening the selector. + */ +export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/ChatFormActionModels.svelte'; + +/** + * Dropdown submenu for managing tool permissions in the chat form. + * + * Displays a collapsible list of available tools organized by group (Built-in / JSON Schema). + * Each group can be expanded to show individual tools with checkboxes for enabling/disabling. + * Provides bulk enable/disable controls per group and shows enabled/total tool counts. + * Opens the tools panel on the server when the menu opens. + * + * Features: + * - Grouped tools with collapsible sections + * - Group favicon display (MCP server icons) + * - Per-group and per-tool toggle checkboxes + * - Loading/error states for tool discovery + * - Integration with toolsPanel for state management + * + * @example + * ```svelte + * + * ``` + */ +export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte'; + +/** + * Dropdown submenu for managing MCP servers in the chat form. + * + * Displays a searchable list of enabled MCP servers with toggle switches + * to enable/disable each server for chat. Shows server favicon, health status, + * and a "Manage MCP Servers" settings link. + * + * Features: + * - Search/filter servers by name or URL + * - Per-server toggle to enable/disable for chat + * - Health check indicator (shows "Error" badge for failed servers) + * - Server favicon display + * - Settings link to manage MCP server configuration + * + * @example + * ```svelte + * + * ``` + */ +export { default as ChatFormActionAddMcpServersSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte'; + +/** + * Hidden file input element for programmatic file selection. + */ +export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileInputInvisible.svelte'; + +/** + * Displays MCP Resource attachments as a horizontal carousel. + * Shows resource name, URI, and allows clicking to view resource content. + */ +export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte'; + +/** + * Auto-resizing textarea with IME composition support. Automatically adjusts + * height based on content. Handles IME input correctly (waits for composition + * end before processing Enter key). Exposes focus() and resetHeight() methods. + */ +export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte'; + +/** + * **ChatFormPickerMcpPrompts** - MCP prompt selection interface + * + * Floating picker for browsing and selecting MCP Server Prompts. + * Triggered by typing `/` in the chat input or choosing `MCP Prompt` option in ChatFormActionAddDropdown. + * Loads prompts from connected MCP servers and allows users to select and configure them. + * + * **Architecture:** + * - Fetches available prompts from mcpStore + * - Manages selection state and keyboard navigation internally + * - Delegates argument input to ChatFormPromptPickerArgumentForm + * - Communicates prompt loading lifecycle via callbacks + * + * **Prompt Loading Flow:** + * 1. User selects prompt → `onPromptLoadStart` called with placeholder ID + * 2. Prompt content fetched from MCP server asynchronously + * 3. On success → `onPromptLoadComplete` with full prompt data + * 4. On failure → `onPromptLoadError` with error details + * + * **Features:** + * - Search/filter prompts by name across all connected servers + * - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close) + * - Argument input forms for prompts with required parameters + * - Autocomplete suggestions for argument values + * - Loading states with skeleton placeholders + * - Server information header per prompt for visual identification + * + * **Exported API:** + * - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled + * + * @example + * ```svelte + * showPicker = false} + * onPromptLoadStart={(id, info) => addPlaceholder(id, info)} + * onPromptLoadComplete={(id, result) => replacePlaceholder(id, result)} + * onPromptLoadError={(id, error) => handleError(id, error)} + * /> + * ``` + */ +export { default as ChatFormPickerMcpPrompts } from './ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte'; + +/** + * Form for entering MCP prompt arguments. Displays input fields for each + * required argument defined by the prompt. Validates input and submits + * when all required fields are filled. Shows argument descriptions as hints. + */ +export { default as ChatFormPromptPickerArgumentForm } from './ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentForm.svelte'; + +/** + * Single argument input field with autocomplete suggestions. Fetches suggestions + * from MCP server based on argument type. Supports keyboard navigation through + * suggestions list. Used within ChatFormPromptPickerArgumentForm. + */ +export { default as ChatFormPromptPickerArgumentInput } from './ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentInput.svelte'; + +/** + * Shared popover wrapper for inline picker popovers (prompts, resources). + * Provides consistent positioning, styling, and open/close behavior. + */ +export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte'; + +/** + * Generic scrollable list for picker popovers. Provides search input, + * scroll-into-view for keyboard navigation, loading skeletons, empty state, + * and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering. + * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources. + */ +export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte'; + +/** + * Generic button wrapper for picker list items. Provides consistent styling, + * hover/selected states, and data-picker-index attribute for scroll-into-view. + * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources. + */ +export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte'; + +/** + * Generic header for picker items displaying server favicon, label, item title, + * and optional description. Accepts `titleExtra` and `subtitle` snippets for + * custom content like badges or URIs. Shared by both pickers. + */ +export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerItemHeader.svelte'; + +/** + * Generic skeleton loading placeholder for picker list items. Configurable + * title width and optional badge skeleton. Shared by both pickers. + */ +export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte'; + +/** + * **ChatFormPickerMcpResources** - MCP resource selection interface + * + * Floating picker for browsing and attaching MCP Server Resources. + * Triggered by typing `@` in the chat input. + * Loads resources from connected MCP servers and allows users to attach them to the chat context. + * + * **Features:** + * - Search/filter resources by name, title, description, or URI across all connected servers + * - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close) + * - Shows attached state for already-attached resources + * - Loading states with skeleton placeholders + * - Server information header per resource for visual identification + * + * **Exported API:** + * - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled + */ +export { default as ChatFormPickerMcpResources } from './ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte'; + +/** + * **ChatFormPickers** - Chat input picker container + * + * Container component that hosts both MCP prompt and MCP resource pickers. + * Manages shared state, keyboard navigation, and coordination between the two + * picker interfaces. Used within ChatForm for `@`-triggered pickers. + */ +export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte'; + +/** + * + * MESSAGES + * + * Components for displaying chat messages. The message system supports: + * - **Conversation branching**: Messages can have siblings (alternative versions) + * created by editing or regenerating. Users can navigate between branches. + * - **Role-based rendering**: Different layouts for user, assistant, and system messages + * - **Streaming support**: Real-time display of assistant responses as they generate + * - **Agentic workflows**: Special rendering for tool calls and reasoning blocks + * + * The branching system uses `getMessageSiblings()` utility to compute sibling info + * for each message based on the full conversation tree stored in the database. + * + */ + +/** + * **ChatMessages** - Message list container with branching support + * + * Container component that renders the list of messages in a conversation. + * Computes sibling information for each message to enable branch navigation. + * Integrates with conversationsStore for message operations. + * + * **Architecture:** + * - Fetches all conversation messages to compute sibling relationships + * - Filters system messages based on user config (`showSystemMessage`) + * - Delegates rendering to ChatMessage for each message + * - Propagates all message operations to chatStore via callbacks + * + * **Branching Logic:** + * - Uses `getMessageSiblings()` to find all messages with same parent + * - Computes `siblingInfo: { currentIndex, totalSiblings, siblingIds }` + * - Enables navigation between alternative message versions + * + * **Message Operations (delegated to chatStore):** + * - Edit with branching: Creates new message branch, preserves original + * - Edit with replacement: Modifies message in place + * - Regenerate: Creates new assistant response as sibling + * - Delete: Removes message and all descendants (cascade) + * - Continue: Appends to incomplete assistant message + * + * @example + * ```svelte + * + * ``` + */ +export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte'; + +/** + * **ChatMessage** - Single message display with actions + * + * Renders a single chat message with role-specific styling and full action + * support. Delegates to specialized components based on message role: + * ChatMessageUser, ChatMessageAssistant, or ChatMessageSystem. + * + * **Architecture:** + * - Routes to role-specific component based on `message.type` + * - Manages edit mode state and inline editing UI + * - Handles action callbacks (copy, edit, delete, regenerate) + * - Displays branching controls when message has siblings + * + * **User Messages:** + * - Shows attachments via ChatAttachments + * - Displays MCP prompts if present + * - Edit creates new branch or preserves responses + * + * **Assistant Messages:** + * - Renders content via MarkdownContent or ChatMessageAgenticContent + * - Shows model info badge (when enabled) + * - Regenerate creates sibling with optional model override + * - Continue action for incomplete responses + * + * **Features:** + * - Inline editing with file attachments support + * - Copy formatted content to clipboard + * - Delete with confirmation (shows cascade delete count) + * - Branching controls for sibling navigation + * - Statistics display (tokens, timing) + * + * @example + * ```svelte + * + * ``` + */ +export { default as ChatMessage } from './ChatMessages/ChatMessage/ChatMessage.svelte'; + +/** + * **ChatMessageAgenticContent** - Agentic workflow output display + * + * Specialized renderer for assistant messages with tool calls and reasoning. + * Derives display sections from structured message data (toolCalls, reasoningContent, + * and child tool result messages) and renders them as interactive collapsible sections. + * + * **Architecture:** + * - Uses `deriveAgenticSections()` from `$lib/utils` to build sections from structured data + * - Renders sections as CollapsibleContentBlock components + * - Handles streaming state for progressive content display + * - Falls back to MarkdownContent for plain text sections + * + * **Execution States:** + * - **Streaming**: Animated spinner, block expanded, auto-scroll enabled + * - **Pending**: Waiting indicator for queued tool calls + * - **Completed**: Static display, block collapsed by default + * + * **Features:** + * - JSON arguments syntax highlighting via SyntaxHighlightedCode + * - Tool results display with formatting + * - Plain text sections between markers rendered as markdown + * - Smart collapse defaults (expanded while streaming, collapsed when done) + * + * @example + * ```svelte + * + * ``` + */ +export { default as ChatMessageAgenticContent } from './ChatMessages/ChatMessageAgenticContent.svelte'; +export { default as ChatMessageActionCardPermissionRequest } from './ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte'; +export { default as ChatMessageActionCard } from './ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte'; +export { default as ChatMessageActionCardContinueRequest } from './ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardContinueRequest.svelte'; + +/** + * Action buttons toolbar for messages. Displays copy, edit, delete, and regenerate + * buttons based on message role. Includes branching controls when message has siblings. + * Shows delete confirmation dialog with cascade delete count. Handles raw output toggle + * for assistant messages. + */ +export { default as ChatMessageActionIcons } from './ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte'; + +/** + * Navigation controls for message siblings (conversation branches). Displays + * prev/next arrows with current position counter (e.g., "2/5"). Enables users + * to navigate between alternative versions of a message created by editing + * or regenerating. Uses `conversationsStore.navigateToSibling()` for navigation. + */ +export { default as ChatMessageActionIconsBranchingControls } from './ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIconsBranchingControls.svelte'; + +/** + * Statistics display for assistant messages. Shows token counts (prompt/completion), + * generation timing, tokens per second, and model name (when enabled in settings). + * Data sourced from message.timings stored during generation. + */ +export { default as ChatMessageStatistics } from './ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte'; +export { default as ChatMessageStatisticsBadge } from './ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte'; + +/** + * MCP prompt display in user messages. Shows when user selected an MCP prompt + * via ChatFormPickerMcpPrompts. Displays server name, prompt name, and expandable + * content preview. Stored in message.extra as DatabaseMessageExtraMcpPrompt. + */ +export { default as ChatMessageMcpPrompt } from './ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte'; + +/** + * Formatted content display for MCP prompt messages. Renders the full prompt + * content with arguments in a readable format. Used within ChatMessageMcpPrompt + * for the expanded view. + */ +export { default as ChatMessageMcpPromptContent } from './ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPromptContent.svelte'; + +/** + * Assistant message display component. Renders assistant responses with left-aligned styling. + * Supports both plain markdown content (via MarkdownContent) and agentic content with tool calls + * (via ChatMessageAgenticContent). Shows model info badge, statistics, and action buttons. + * Handles streaming state with real-time content updates. + */ +export { default as ChatMessageAssistant } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte'; + +/** + * Inline message editing form. Provides textarea for editing message content with + * attachment management. Shows save/cancel buttons and optional "Save only" button + * for editing without regenerating responses. Used within ChatMessage components + * when user enters edit mode. + */ +export { default as ChatMessageEditForm } from './ChatMessages/ChatMessageEditForm.svelte'; + +/** + * User message display component. Renders user messages with right-aligned bubble styling. + * Shows message content, attachments via ChatAttachmentsList, and MCP prompts if present. + * Supports inline editing mode with ChatMessageEditForm integration. + */ +export { default as ChatMessageUser } from './ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte'; +export { default as ChatMessageUserBubble } from './ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte'; +export { default as ChatMessageUserPending } from './ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte'; + +/** + * System message display component. Renders system messages with distinct styling. + * Visibility controlled by `showSystemMessage` config setting. + */ +export { default as ChatMessageSystem } from './ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte'; + +/** + * + * SCREEN + * + * Top-level chat interface components. ChatScreen is the main container that + * orchestrates all chat functionality. It integrates with multiple stores: + * - `chatStore` for message operations and generation control + * - `conversationsStore` for conversation management + * - `serverStore` for server connection state + * - `modelsStore` for model capabilities (vision, audio modalities) + * + * The screen handles the complete chat lifecycle from empty state to active + * conversation with streaming responses. + * + */ + +/** + * **ChatScreen** - Main chat interface container + * + * Top-level component that orchestrates the entire chat interface. Manages + * messages display, input form, file handling, auto-scroll, error dialogs, + * and server state. Used as the main content area in chat routes. + * + * **Architecture:** + * - Composes ChatMessages, ChatScreenForm, and dialogs + * - Manages auto-scroll via `createAutoScrollController()` hook + * - Handles file upload pipeline (validation → processing → state update) + * - Integrates with serverStore for loading/error/warning states + * - Tracks active model for modality validation (vision, audio) + * + * **File Upload Pipeline:** + * 1. Files received via drag-drop, paste, or file picker + * 2. Validated against supported types (`isFileTypeSupported()`) + * 3. Filtered by model modalities (`filterFilesByModalities()`) + * 4. Empty files detected and reported via DialogEmptyFileAlert + * 5. Valid files processed to ChatUploadedFile[] format + * 6. Unsupported files shown in error dialog with reasons + * + * **State Management:** + * - `isEmpty`: Shows centered welcome UI when no conversation active + * - `isCurrentConversationLoading`: Tracks generation state for current chat + * - `activeModelId`: Determines available modalities for file validation + * - `uploadedFiles`: Pending file attachments for next message + * + * **Features:** + * - Messages display with smart auto-scroll (pauses on user scroll up) + * - File drag-drop with visual overlay indicator + * - File validation with detailed error messages + * - Error dialog management (chat errors, model unavailable) + * - Server loading/error/warning states with appropriate UI + * - Conversation deletion with confirmation dialog + * - Processing info display (tokens/sec, timing) during generation + * - Keyboard shortcuts (Ctrl+Shift+Backspace to delete conversation) + * + * @example + * ```svelte + * + * + * + * + * + * ``` + */ +export { default as ChatScreen } from './ChatScreen/ChatScreen.svelte'; + +/** + * Visual overlay displayed when user drags files over the chat screen. + * Shows drop zone indicator to guide users where to release files. + * Integrated with ChatScreen's drag-drop file upload handling. + */ +export { default as ChatScreenDragOverlay } from './ChatScreen/ChatScreenDragOverlay.svelte'; + +/** + * Chat form wrapper within ChatScreen. Positions the ChatForm component at the + * bottom of the screen with proper padding and max-width constraints. Handles + * the visual container styling for the input area. + */ +export { default as ChatScreenForm } from './ChatScreen/ChatScreenForm.svelte'; + +/** + * Processing info display during generation. Shows real-time statistics: + * tokens per second, prompt/completion token counts, and elapsed time. + * Data sourced from slotsService polling during active generation. + * Only visible when `isCurrentConversationLoading` is true. + */ +export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProcessingInfo.svelte'; diff --git a/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte new file mode 100644 index 000000000..b7297ab6b --- /dev/null +++ b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte @@ -0,0 +1,98 @@ + + + { + open = value; + onToggle?.(); + }} + class={className} +> + + +
        + {#if IconComponent} + + {/if} + + {title} + + {#if subtitle} + {subtitle} + {/if} +
        + +
        + + + Toggle content +
        +
        + + +
        + {@render children()} +
        +
        +
        +
        diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte new file mode 100644 index 000000000..c1b71e451 --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte @@ -0,0 +1,1223 @@ + + +
        + {#each renderedBlocks as block (block.id)} +
        + + {@html block.html} +
        + {/each} + + {#if unstableBlockHtml} +
        + + {@html unstableBlockHtml} +
        + {/if} + + {#if incompleteCodeBlock} +
        +
        + {incompleteCodeBlock.language || 'text'} + { + previewCode = code; + previewLanguage = lang; + previewDialogOpen = true; + }} + /> +
        +
        streamingAutoScroll.handleScroll()} + > +
        {@html highlightCode(
        +							incompleteCodeBlock.code,
        +							incompleteCodeBlock.language || 'text'
        +						)}
        +
        +
        + {/if} +
        + + + + diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-code-blocks.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-code-blocks.ts new file mode 100644 index 000000000..9d9348a5f --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-code-blocks.ts @@ -0,0 +1,171 @@ +/** + * Rehype plugin to enhance code blocks with wrapper, header, and action buttons. + * + * Wraps
         elements with a container that includes:
        + * - Language label
        + * - Copy button
        + * - Preview button (for HTML code blocks)
        + *
        + * This operates directly on the HAST tree for better performance,
        + * avoiding the need to stringify and re-parse HTML.
        + */
        +
        +import type { Plugin } from 'unified';
        +import type { Root, Element, ElementContent } from 'hast';
        +import { visit } from 'unist-util-visit';
        +import {
        +	CODE_BLOCK_SCROLL_CONTAINER_CLASS,
        +	CODE_BLOCK_WRAPPER_CLASS,
        +	CODE_BLOCK_HEADER_CLASS,
        +	CODE_BLOCK_ACTIONS_CLASS,
        +	CODE_LANGUAGE_CLASS,
        +	COPY_CODE_BTN_CLASS,
        +	PREVIEW_CODE_BTN_CLASS,
        +	RELATIVE_CLASS
        +} from '$lib/constants';
        +
        +declare global {
        +	interface Window {
        +		idxCodeBlock?: number;
        +	}
        +}
        +
        +const COPY_ICON_SVG = ``;
        +
        +const PREVIEW_ICON_SVG = ``;
        +
        +function createIconElement(svg: string): Element {
        +	return {
        +		type: 'element',
        +		tagName: 'span',
        +		properties: {},
        +		children: [{ type: 'raw', value: svg } as unknown as ElementContent]
        +	};
        +}
        +
        +function createButton(className: string, title: string, iconSvg: string, codeId: string): Element {
        +	return {
        +		type: 'element',
        +		tagName: 'button',
        +		properties: {
        +			className: [className],
        +			'data-code-id': codeId,
        +			title,
        +			type: 'button'
        +		},
        +		children: [createIconElement(iconSvg)]
        +	};
        +}
        +
        +function createCopyButton(codeId: string): Element {
        +	return createButton(COPY_CODE_BTN_CLASS, 'Copy code', COPY_ICON_SVG, codeId);
        +}
        +
        +function createPreviewButton(codeId: string): Element {
        +	return createButton(PREVIEW_CODE_BTN_CLASS, 'Preview code', PREVIEW_ICON_SVG, codeId);
        +}
        +
        +function createHeader(language: string, codeId: string): Element {
        +	const actions: Element[] = [createCopyButton(codeId)];
        +
        +	if (language.toLowerCase() === 'html') {
        +		actions.push(createPreviewButton(codeId));
        +	}
        +
        +	return {
        +		type: 'element',
        +		tagName: 'div',
        +		properties: { className: [CODE_BLOCK_HEADER_CLASS] },
        +		children: [
        +			{
        +				type: 'element',
        +				tagName: 'span',
        +				properties: { className: [CODE_LANGUAGE_CLASS] },
        +				children: [{ type: 'text', value: language }]
        +			},
        +			{
        +				type: 'element',
        +				tagName: 'div',
        +				properties: { className: [CODE_BLOCK_ACTIONS_CLASS] },
        +				children: actions
        +			}
        +		]
        +	};
        +}
        +
        +function createScrollContainer(preElement: Element): Element {
        +	return {
        +		type: 'element',
        +		tagName: 'div',
        +		properties: { className: [CODE_BLOCK_SCROLL_CONTAINER_CLASS] },
        +		children: [preElement]
        +	};
        +}
        +
        +function createWrapper(header: Element, preElement: Element): Element {
        +	return {
        +		type: 'element',
        +		tagName: 'div',
        +		properties: { className: [CODE_BLOCK_WRAPPER_CLASS, RELATIVE_CLASS] },
        +		children: [header, createScrollContainer(preElement)]
        +	};
        +}
        +
        +function extractLanguage(codeElement: Element): string {
        +	const className = codeElement.properties?.className;
        +	if (!Array.isArray(className)) return 'text';
        +
        +	for (const cls of className) {
        +		if (typeof cls === 'string' && cls.startsWith('language-')) {
        +			return cls.replace('language-', '');
        +		}
        +	}
        +
        +	return 'text';
        +}
        +
        +/**
        + * Generates a unique code block ID using a global counter.
        + */
        +function generateCodeId(): string {
        +	if (typeof window !== 'undefined') {
        +		return `code-${(window.idxCodeBlock = (window.idxCodeBlock ?? 0) + 1)}`;
        +	}
        +	// Fallback for SSR - use timestamp + random
        +	return `code-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
        +}
        +
        +/**
        + * Rehype plugin to enhance code blocks with wrapper, header, and action buttons.
        + * This plugin wraps 
         elements with a container that includes:
        + * - Language label
        + * - Copy button
        + * - Preview button (for HTML code blocks)
        + */
        +export const rehypeEnhanceCodeBlocks: Plugin<[], Root> = () => {
        +	return (tree: Root) => {
        +		visit(tree, 'element', (node: Element, index, parent) => {
        +			if (node.tagName !== 'pre' || !parent || index === undefined) return;
        +
        +			const codeElement = node.children.find(
        +				(child): child is Element => child.type === 'element' && child.tagName === 'code'
        +			);
        +
        +			if (!codeElement) return;
        +
        +			const language = extractLanguage(codeElement);
        +			const codeId = generateCodeId();
        +
        +			codeElement.properties = {
        +				...codeElement.properties,
        +				'data-code-id': codeId
        +			};
        +
        +			const header = createHeader(language, codeId);
        +			const wrapper = createWrapper(header, node);
        +
        +			// Replace pre with wrapper in parent
        +			(parent.children as ElementContent[])[index] = wrapper;
        +		});
        +	};
        +};
        diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-links.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-links.ts
        new file mode 100644
        index 000000000..b5fbcbdaa
        --- /dev/null
        +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-links.ts
        @@ -0,0 +1,33 @@
        +/**
        + * Rehype plugin to enhance links with security attributes.
        + *
        + * Adds target="_blank" and rel="noopener noreferrer" to all anchor elements,
        + * ensuring external links open in new tabs safely.
        + */
        +
        +import type { Plugin } from 'unified';
        +import type { Root, Element } from 'hast';
        +import { visit } from 'unist-util-visit';
        +
        +/**
        + * Rehype plugin that adds security attributes to all links.
        + * This plugin ensures external links open in new tabs safely by adding:
        + * - target="_blank"
        + * - rel="noopener noreferrer"
        + */
        +export const rehypeEnhanceLinks: Plugin<[], Root> = () => {
        +	return (tree: Root) => {
        +		visit(tree, 'element', (node: Element) => {
        +			if (node.tagName !== 'a') return;
        +
        +			const props = node.properties ?? {};
        +
        +			// Only modify if href exists
        +			if (!props.href) return;
        +
        +			props.target = '_blank';
        +			props.rel = 'noopener noreferrer';
        +			node.properties = props;
        +		});
        +	};
        +};
        diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/rehype-rtl-support.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/rehype-rtl-support.ts
        new file mode 100644
        index 000000000..0a8b93ad5
        --- /dev/null
        +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/rehype-rtl-support.ts
        @@ -0,0 +1,28 @@
        +/**
        + * Rehype plugin to provide comprehensive RTL support by adding dir="auto"
        + * to all text-containing elements.
        + *
        + * This operates directly on the HAST tree, ensuring that all elements
        + * (including those not in a predefined list) receive the attribute.
        + */
        +
        +import type { Plugin } from 'unified';
        +import type { Root, Element } from 'hast';
        +import { visit } from 'unist-util-visit';
        +
        +/**
        + * Rehype plugin to add dir="auto" to all elements that have children.
        + * This provides bidirectional text support for mixed RTL/LTR content.
        + */
        +export const rehypeRtlSupport: Plugin<[], Root> = () => {
        +	return (tree: Root) => {
        +		visit(tree, 'element', (node: Element) => {
        +			if (node.children && node.children.length > 0) {
        +				node.properties = {
        +					...node.properties,
        +					dir: 'auto'
        +				};
        +			}
        +		});
        +	};
        +};
        diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/resolve-attachment-images.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/resolve-attachment-images.ts
        new file mode 100644
        index 000000000..36e7a3192
        --- /dev/null
        +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/resolve-attachment-images.ts
        @@ -0,0 +1,34 @@
        +import type { Root as HastRoot } from 'hast';
        +import { visit } from 'unist-util-visit';
        +import type { DatabaseMessageExtra, DatabaseMessageExtraImageFile } from '$lib/types/database';
        +import { AttachmentType, UrlProtocol } from '$lib/enums';
        +
        +/**
        + * Rehype plugin to resolve attachment image sources.
        + * Converts attachment names (e.g., "mcp-attachment-xxx.png") to base64 data URLs.
        + */
        +export function rehypeResolveAttachmentImages(options: { attachments?: DatabaseMessageExtra[] }) {
        +	return (tree: HastRoot) => {
        +		visit(tree, 'element', (node) => {
        +			if (node.tagName === 'img' && node.properties?.src) {
        +				const src = String(node.properties.src);
        +
        +				// Skip data URLs and external URLs
        +				if (src.startsWith(UrlProtocol.DATA) || src.startsWith(UrlProtocol.HTTP)) {
        +					return;
        +				}
        +
        +				// Find matching attachment
        +				const attachment = options.attachments?.find(
        +					(a): a is DatabaseMessageExtraImageFile =>
        +						a.type === AttachmentType.IMAGE && a.name === src
        +				);
        +
        +				// Replace with base64 URL if found
        +				if (attachment?.base64Url) {
        +					node.properties.src = attachment.base64Url;
        +				}
        +			}
        +		});
        +	};
        +}
        diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/table-html-restorer.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/table-html-restorer.ts
        new file mode 100644
        index 000000000..bc5d03465
        --- /dev/null
        +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/table-html-restorer.ts
        @@ -0,0 +1,181 @@
        +/**
        + * Rehype plugin to restore limited HTML elements inside Markdown table cells.
        + *
        + * ## Problem
        + * The remark/rehype pipeline neutralizes inline HTML as literal text
        + * (remarkLiteralHtml) so that XML/HTML snippets in LLM responses display
        + * as-is instead of being rendered. This causes 
        and
          markup in + * table cells to show as plain text. + * + * ## Solution + * This plugin traverses the HAST post-conversion, parses whitelisted HTML + * patterns from text nodes, and replaces them with actual HAST element nodes + * that will be rendered as real HTML. + * + * ## Supported HTML + * - `
          ` / `
          ` / `
          ` - Line breaks (inline) + * - `
          • ...
          ` - Unordered lists (block) + * + * ## Key Implementation Details + * + * ### 1. Sibling Combination (Critical) + * The Markdown pipeline may fragment content across multiple text nodes and `
          ` + * elements. For example, `
          • a
          ` might arrive as: + * - Text: `"
            "` + * - Element: `
            ` + * - Text: `"
          • a
          "` + * + * We must combine consecutive text nodes and `
          ` elements into a single string + * before attempting to parse list markup. Without this, list detection fails. + * + * ### 2. visitParents for Deep Traversal + * Table cell content may be wrapped in intermediate elements (e.g., `

          ` tags). + * Using `visitParents` instead of direct child iteration ensures we find text + * nodes at any depth within the cell. + * + * ### 3. Reference Comparison for No-Op Detection + * When checking if `
          ` expansion changed anything, we compare: + * `expanded.length !== 1 || expanded[0] !== textNode` + * + * This catches both cases: + * - Multiple nodes created (text was split) + * - Single NEW node created (original had only `
          `, now it's an element) + * + * A simple `length > 1` check would miss the single `
          ` case. + * + * ### 4. Strict List Validation + * `parseList()` rejects malformed markup by checking for garbage text between + * `

        • ` elements. This prevents creating broken DOM from partial matches like + * `
            garbage
          • a
          `. + * + * ### 5. Newline Substitution for `
          ` in Combined String + * When combining siblings, existing `
          ` elements become `\n` in the combined + * string. This allows list content to span visual lines while still being parsed + * as a single unit. + * + * @example + * // Input Markdown: + * // | Feature | Notes | + * // |---------|-------| + * // | Multi-line | First
          Second | + * // | List |
          • A
          • B
          | + * // + * // Without this plugin:
          and
            render as literal text + * // With this plugin:
            becomes line break,
              becomes actual list + */ + +import type { Plugin } from 'unified'; +import type { Element, ElementContent, Root, Text } from 'hast'; +import { visit } from 'unist-util-visit'; +import { visitParents } from 'unist-util-visit-parents'; +import { BR_PATTERN, LIST_PATTERN, LI_PATTERN } from '$lib/constants'; + +/** + * Expands text containing `
              ` tags into an array of text nodes and br elements. + */ +function expandBrTags(value: string): ElementContent[] { + const matches = [...value.matchAll(BR_PATTERN)]; + if (!matches.length) return [{ type: 'text', value } as Text]; + + const result: ElementContent[] = []; + let cursor = 0; + + for (const m of matches) { + if (m.index! > cursor) { + result.push({ type: 'text', value: value.slice(cursor, m.index) } as Text); + } + result.push({ type: 'element', tagName: 'br', properties: {}, children: [] } as Element); + cursor = m.index! + m[0].length; + } + + if (cursor < value.length) { + result.push({ type: 'text', value: value.slice(cursor) } as Text); + } + + return result; +} + +/** + * Parses a `
              • ...
              ` string into a HAST element. + * Returns null if the markup is malformed or contains unexpected content. + */ +function parseList(value: string): Element | null { + const match = value.trim().match(LIST_PATTERN); + if (!match) return null; + + const body = match[1]; + const items: ElementContent[] = []; + let cursor = 0; + + for (const liMatch of body.matchAll(LI_PATTERN)) { + // Reject if there's non-whitespace between list items + if (body.slice(cursor, liMatch.index!).trim()) return null; + + items.push({ + type: 'element', + tagName: 'li', + properties: {}, + children: expandBrTags(liMatch[1] ?? '') + } as Element); + + cursor = liMatch.index! + liMatch[0].length; + } + + // Reject if no items found or trailing garbage exists + if (!items.length || body.slice(cursor).trim()) return null; + + return { type: 'element', tagName: 'ul', properties: {}, children: items } as Element; +} + +/** + * Processes a single table cell, restoring HTML elements from text content. + */ +function processCell(cell: Element) { + visitParents(cell, 'text', (textNode: Text, ancestors) => { + const parent = ancestors[ancestors.length - 1]; + if (!parent || parent.type !== 'element') return; + + const parentEl = parent as Element; + const siblings = parentEl.children as ElementContent[]; + const startIndex = siblings.indexOf(textNode as ElementContent); + if (startIndex === -1) return; + + // Combine consecutive text nodes and
              elements into one string + let combined = ''; + let endIndex = startIndex; + + for (let i = startIndex; i < siblings.length; i++) { + const sib = siblings[i]; + if (sib.type === 'text') { + combined += (sib as Text).value; + endIndex = i; + } else if (sib.type === 'element' && (sib as Element).tagName === 'br') { + combined += '\n'; + endIndex = i; + } else { + break; + } + } + + // Try parsing as list first (replaces entire combined range) + const list = parseList(combined); + if (list) { + siblings.splice(startIndex, endIndex - startIndex + 1, list); + return; + } + + // Otherwise, just expand
              tags in this text node + const expanded = expandBrTags(textNode.value); + if (expanded.length !== 1 || expanded[0] !== textNode) { + siblings.splice(startIndex, 1, ...expanded); + } + }); +} + +export const rehypeRestoreTableHtml: Plugin<[], Root> = () => (tree) => { + visit(tree, 'element', (node: Element) => { + if (node.tagName === 'td' || node.tagName === 'th') { + processCell(node); + } + }); +}; diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/remark/literal-html.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/remark/literal-html.ts new file mode 100644 index 000000000..c974d8b18 --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/remark/literal-html.ts @@ -0,0 +1,121 @@ +import type { Plugin } from 'unified'; +import { visit } from 'unist-util-visit'; +import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast'; +import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants'; + +/** + * remark plugin that rewrites raw HTML nodes into plain-text equivalents. + * + * remark parses inline HTML into `html` nodes even when we do not want to render + * them. We turn each of those nodes into regular text (plus `
              ` break markers) + * so the downstream rehype pipeline escapes the characters instead of executing + * them. Leading spaces and tab characters are converted to non‑breaking spaces to + * keep indentation identical to the original author input. + */ + +function preserveIndent(line: string): string { + let index = 0; + let output = ''; + + while (index < line.length) { + const char = line[index]; + + if (char === ' ') { + output += NBSP; + index += 1; + continue; + } + + if (char === '\t') { + output += TAB_AS_SPACES; + index += 1; + continue; + } + + break; + } + + return output + line.slice(index); +} + +function createLiteralChildren(value: string): PhrasingContent[] { + const lines = value.split(LINE_BREAK); + const nodes: PhrasingContent[] = []; + + for (const [lineIndex, rawLine] of lines.entries()) { + if (lineIndex > 0) { + nodes.push({ type: 'break' } as Break as unknown as PhrasingContent); + } + + nodes.push({ + type: 'text', + value: preserveIndent(rawLine) + } as Text as unknown as PhrasingContent); + } + + if (!nodes.length) { + nodes.push({ type: 'text', value: '' } as Text as unknown as PhrasingContent); + } + + return nodes; +} + +export const remarkLiteralHtml: Plugin<[], Root> = () => { + return (tree) => { + visit(tree, 'html', (node, index, parent) => { + if (!parent || typeof index !== 'number') { + return; + } + + const replacement = createLiteralChildren(node.value); + + if (!PHRASE_PARENTS.has(parent.type as string)) { + const paragraph: Paragraph = { + type: 'paragraph', + children: replacement as Paragraph['children'], + data: { literalHtml: true } + }; + + const siblings = parent.children as unknown as Content[]; + siblings.splice(index, 1, paragraph as unknown as Content); + + if (index > 0) { + const previous = siblings[index - 1] as Paragraph | undefined; + + if ( + previous?.type === 'paragraph' && + (previous.data as { literalHtml?: boolean } | undefined)?.literalHtml + ) { + const prevChildren = previous.children as unknown as PhrasingContent[]; + + if (prevChildren.length) { + const lastChild = prevChildren[prevChildren.length - 1]; + + if (lastChild.type !== 'break') { + prevChildren.push({ + type: 'break' + } as Break as unknown as PhrasingContent); + } + } + + prevChildren.push(...(paragraph.children as unknown as PhrasingContent[])); + + siblings.splice(index, 1); + + return index; + } + } + + return index + 1; + } + + (parent.children as unknown as PhrasingContent[]).splice( + index, + 1, + ...(replacement as unknown as PhrasingContent[]) + ); + + return index + replacement.length; + }); + }; +}; diff --git a/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte b/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte new file mode 100644 index 000000000..41d59324c --- /dev/null +++ b/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte @@ -0,0 +1,96 @@ + + +
              + +
              {@html highlightedHtml}
              +
              + + diff --git a/tools/ui/src/lib/components/app/content/index.ts b/tools/ui/src/lib/components/app/content/index.ts new file mode 100644 index 000000000..e468a441e --- /dev/null +++ b/tools/ui/src/lib/components/app/content/index.ts @@ -0,0 +1,79 @@ +/** + * + * CONTENT RENDERING + * + * Components for rendering rich content: markdown, code, and previews. + * + */ + +/** + * **MarkdownContent** - Rich markdown renderer + * + * Renders markdown content with syntax highlighting, LaTeX math, + * tables, links, and code blocks. Optimized for streaming with + * incremental block-based rendering. + * + * **Features:** + * - GFM (GitHub Flavored Markdown): tables, task lists, strikethrough + * - LaTeX math via KaTeX (`$inline$` and `$$block$$`) + * - Syntax highlighting (highlight.js) with language detection + * - Code copy buttons with click feedback + * - External links open in new tab with security attrs + * - Image attachment resolution from message extras + * - Dark/light theme support (auto-switching) + * - Streaming-optimized incremental rendering + * - Code preview dialog for large blocks + * + * @example + * ```svelte + * + * ``` + */ +export { default as MarkdownContent } from './MarkdownContent/MarkdownContent.svelte'; + +/** + * **SyntaxHighlightedCode** - Code syntax highlighting + * + * Renders code with syntax highlighting using highlight.js. + * Supports theme switching and scrollable containers. + * + * **Features:** + * - Auto language detection with fallback + * - Dark/light theme auto-switching + * - Scrollable container with configurable max dimensions + * - Monospace font styling + * - Preserves whitespace and formatting + * + * @example + * ```svelte + * + * ``` + */ +export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte'; + +/** + * **CollapsibleContentBlock** - Expandable content card + * + * Reusable collapsible card with header, icon, and auto-scroll. + * Used for tool calls and reasoning blocks in chat messages. + * + * **Features:** + * - Collapsible content with smooth animation + * - Custom icon and title display + * - Optional subtitle/status text + * - Auto-scroll during streaming (pauses on user scroll) + * - Configurable max height with overflow scroll + * + * @example + * ```svelte + * + * {reasoningContent} + * + * ``` + */ +export { default as CollapsibleContentBlock } from './CollapsibleContentBlock.svelte'; diff --git a/tools/ui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte b/tools/ui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte new file mode 100644 index 000000000..533301dfd --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + diff --git a/tools/ui/src/lib/components/app/dialogs/DialogChatError.svelte b/tools/ui/src/lib/components/app/dialogs/DialogChatError.svelte new file mode 100644 index 000000000..ff1005313 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogChatError.svelte @@ -0,0 +1,80 @@ + + + + + + + {#if isTimeout} + + {:else} + + {/if} + + {title} + + + + {description} + + + +
              +

              {message}

              + + {#if contextInfo} +
              +

              + Prompt tokens: + + {contextInfo.n_prompt_tokens.toLocaleString()} +

              + + {#if contextInfo.n_ctx} +

              + Context size: + + {contextInfo.n_ctx.toLocaleString()} +

              + {/if} +
              + {/if} +
              + + + handleOpenChange(false)}>Close + +
              +
              diff --git a/tools/ui/src/lib/components/app/dialogs/DialogCodePreview.svelte b/tools/ui/src/lib/components/app/dialogs/DialogCodePreview.svelte new file mode 100644 index 000000000..fe5d9b504 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogCodePreview.svelte @@ -0,0 +1,95 @@ + + + + + + + + + + + + + Close preview + + + + + + diff --git a/tools/ui/src/lib/components/app/dialogs/DialogConfirmation.svelte b/tools/ui/src/lib/components/app/dialogs/DialogConfirmation.svelte new file mode 100644 index 000000000..becc658d3 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogConfirmation.svelte @@ -0,0 +1,81 @@ + + + + + + + {#if icon} + {@const IconComponent = icon} + + + {/if} + {title} + + + + {description} + + + + {#if children} + {@render children()} + {/if} + + + {cancelText} + + {confirmText} + + + + diff --git a/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte b/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte new file mode 100644 index 000000000..737325085 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte @@ -0,0 +1,69 @@ + + + + + + + + + + Select Conversations to {mode === 'export' ? 'Export' : 'Import'} + + + + {#if mode === 'export'} + Choose which conversations you want to export. Selected conversations will be downloaded + as a JSON file. + {:else} + Choose which conversations you want to import. Selected conversations will be merged + with your existing conversations. + {/if} + + + + + + + diff --git a/tools/ui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte b/tools/ui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte new file mode 100644 index 000000000..4a9eccef7 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte @@ -0,0 +1,46 @@ + + + + + + Update Conversation Title? + + + Do you want to update the conversation title to match the first message content? + + + +
              +
              +

              Current title:

              + +

              {currentTitle}

              +
              + +
              +

              New title would be:

              + +

              {newTitle}

              +
              +
              + + + + + + +
              +
              diff --git a/tools/ui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte b/tools/ui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte new file mode 100644 index 000000000..f875b0aba --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte @@ -0,0 +1,61 @@ + + + + + + + + + Empty Files Detected + + + + The following files are empty and have been removed from your attachments: + + + +
              +
              +
              Empty Files:
              + +
                + {#each emptyFiles as fileName (fileName)} +
              • {fileName}
              • + {/each} +
              +
              + +
              +
              What happened:
              + +
                +
              • Empty files cannot be processed or sent to the AI model
              • + +
              • These files have been automatically removed from your attachments
              • + +
              • You can try uploading files with content instead
              • +
              +
              +
              + + + handleOpenChange(false)}>Got it + +
              +
              diff --git a/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte b/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte new file mode 100644 index 000000000..c112bde9f --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte @@ -0,0 +1,83 @@ + + + + + + + {#if includeSensitiveData} + + {:else} + + {/if} + Export Settings + + + + {#if includeSensitiveData} +

              + Warning: This export will include sensitive data such as API keys and MCP server custom + headers (e.g., authorization tokens). Do not share this file with anyone you don't + trust. +

              + {:else} +

              + Sensitive data (API keys, MCP server custom headers) will not be included in the export + to protect your credentials. +

              + {/if} +
              +
              + +
              + + + +
              + + + Cancel + + {#if includeSensitiveData} + Export Anyway + {:else} + Export Without Sensitive Data + {/if} + + +
              +
              diff --git a/tools/ui/src/lib/components/app/dialogs/DialogFileUploadError.svelte b/tools/ui/src/lib/components/app/dialogs/DialogFileUploadError.svelte new file mode 100644 index 000000000..3bb2d357f --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogFileUploadError.svelte @@ -0,0 +1,88 @@ + + + + + + + + + File Upload Error + + + Some files cannot be uploaded with the current model. + + + +
              + {#if fileErrorData.generallyUnsupported.length > 0} +
              +

              Unsupported File Types

              + +
              + {#each fileErrorData.generallyUnsupported as file (file.name)} +
              +

              + {file.name} +

              + +

              File type not supported

              +
              + {/each} +
              +
              + {/if} + + {#if fileErrorData.modalityUnsupported.length > 0} +
              +
              + {#each fileErrorData.modalityUnsupported as file (file.name)} +
              +

              + {file.name} +

              + +

              + {fileErrorData.modalityReasons[file.name] || 'Not supported by current model'} +

              +
              + {/each} +
              +
              + {/if} +
              + +
              +

              This model supports:

              + +

              + {fileErrorData.supportedTypes.join(', ')} +

              +
              + + + handleOpenChange(false)}>Got it + +
              +
              +
              diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcePreview.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcePreview.svelte new file mode 100644 index 000000000..7bf284089 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcePreview.svelte @@ -0,0 +1,122 @@ + + + + + + {extra.name} + + +
              + {extra.uri} + + {#if serverName} + + · + {#if favicon} + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + {/if} + {serverName} + + {/if} + + {#if extra.mimeType} + {extra.mimeType} + {/if} +
              +
              +
              + +
              + + + +
              + +
              + {#if isImageResource(extra.mimeType, extra.uri) && extra.content} +
              + {extra.name} +
              + {:else if isCodeResource(extra.mimeType, extra.uri) && extra.content} + + {:else if extra.content} +
              {extra.content}
              + {:else} +
              No content available
              + {/if} +
              +
              +
              diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte new file mode 100644 index 000000000..eb162a557 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte @@ -0,0 +1,394 @@ + + + + + + + + + MCP Resources + + {#if totalCount > 0} + ({totalCount}) + {/if} + + + + Browse and attach resources from connected MCP servers to your chat context. + + + +
              +
              + +
              + +
              + {#if selectedTemplate && !templatePreviewContent} +
              +
              + + + + {selectedTemplate.title || selectedTemplate.name} + +
              + + {#if selectedTemplate.description} +

              + {selectedTemplate.description} +

              + {/if} + +
              +

              + {selectedTemplate.uriTemplate} +

              +
              + + {#if templatePreviewLoading} +
              + +
              + {:else if templatePreviewError} +
              + {templatePreviewError} + + +
              + {:else} + + {/if} +
              + {:else if hasTemplateResult} + + + {:else if selectedResources.size === 1} + {@const allResources = getAllResourcesFlatInTreeOrder()} + {@const selectedResource = allResources.find((r) => selectedResources.has(r.uri))} + + + {:else if selectedResources.size > 1} +
              + {#each getAllResourcesFlatInTreeOrder() as resource (resource.uri)} + {#if selectedResources.has(resource.uri)} + + {/if} + {/each} +
              + {:else} +
              + Select a resource to preview +
              + {/if} +
              +
              + + + + + {#if hasTemplateResult} + + {:else} + + {/if} + +
              +
              diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte new file mode 100644 index 000000000..349f7e7fb --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte @@ -0,0 +1,88 @@ + + + + + + Add New Server + + +
              + (newServerUrl = v)} + onHeadersChange={(v) => (newServerHeaders = v)} + urlError={newServerUrl ? newServerUrlError : null} + id="new-server" + /> +
              + + + + + + +
              +
              diff --git a/tools/ui/src/lib/components/app/dialogs/DialogModelInformation.svelte b/tools/ui/src/lib/components/app/dialogs/DialogModelInformation.svelte new file mode 100644 index 000000000..5a10859a0 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogModelInformation.svelte @@ -0,0 +1,270 @@ + + + + + + + + Model Information + + Current model details and capabilities + + +
              + {#if isLoadingModels || isLoadingRouterProps} +
              +
              Loading model information...
              +
              + {:else if firstModel} + {@const modelMeta = firstModel.meta} + + {#if serverProps} + + + + Model + + +
              + + {modelName} + + + +
              +
              +
              +
              + + + + File Path + + + + {serverProps.model_path} + + + + + + + + {#if serverProps?.default_generation_settings?.n_ctx} + + Context Size + + {formatNumber(serverProps.default_generation_settings.n_ctx)} tokens + + {:else} + + Context Size + + Not available + + {/if} + + + {#if modelMeta?.n_ctx_train} + + Training Context + + {formatNumber(modelMeta.n_ctx_train)} tokens + + {/if} + + + {#if modelMeta?.size} + + Model Size + + {formatFileSize(modelMeta.size)} + + {/if} + + + {#if modelMeta?.n_params} + + Parameters + + {formatParameters(modelMeta.n_params)} + + {/if} + + + {#if modelMeta?.n_embd} + + Embedding Size + + {formatNumber(modelMeta.n_embd)} + + {/if} + + + {#if modelMeta?.n_vocab} + + Vocabulary Size + + {formatNumber(modelMeta.n_vocab)} tokens + + {/if} + + + {#if modelMeta?.vocab_type} + + Vocabulary Type + {modelMeta.vocab_type} + + {/if} + + + + Parallel Slots + + {serverProps.total_slots} + + + + {#if modalities.length > 0} + + Modalities + + +
              + +
              +
              +
              + {/if} + + + + Build Info + + {serverProps.build_info} + + + + {#if serverProps.chat_template} + + Chat Template + + +
              +
              {serverProps.chat_template}
              +
              +
              +
              + {/if} +
              +
              + {/if} + {:else if !isLoadingModels} +
              +
              No model information available
              +
              + {/if} +
              +
              +
              diff --git a/tools/ui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte b/tools/ui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte new file mode 100644 index 000000000..a6c20291f --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte @@ -0,0 +1,76 @@ + + + + + + + + Model Not Available + + + + The requested model could not be found. Select an available model to continue. + + + +
              +
              +

              + Requested: {modelName} +

              +
              + + {#if availableModels.length > 0} +
              +

              Select an available model:

              +
              + {#each availableModels as model (model)} + + {/each} +
              +
              + {/if} +
              + + + handleOpenChange(false)}>Cancel + +
              +
              diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts new file mode 100644 index 000000000..5a6453b72 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/index.ts @@ -0,0 +1,476 @@ +/** + * + * DIALOGS + * + * Modal dialog components for the chat application. + * + * All dialogs use ShadCN Dialog or AlertDialog components for consistent + * styling, accessibility, and animation. They integrate with application + * stores for state management and data access. + * + */ + +/** + * **DialogMcpServerAddNew** - Add new MCP server dialog + * + * Modal dialog for adding a new MCP server with URL and optional headers. + * Validates URL format and integrates with mcpStore and conversationsStore. + */ +export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte'; + +/** + * **DialogExportSettings** - Settings export dialog with sensitive data warning + * + * Dialog for exporting settings with an option to include or exclude + * sensitive data (API keys, MCP server custom headers). Defaults to excluding + * sensitive data for security. User must explicitly opt-in to include them. + * + * **Architecture:** + * - Uses ShadCN AlertDialog + * - Checkbox to toggle sensitive data inclusion (defaults to false) + * - Warning icon and message when sensitive data is included + * - Destructive variant for the action button when exporting with sensitive data + * + * **Features:** + * - Secure default: sensitive data excluded by default + * - User must explicitly opt-in to include sensitive data + * - Visual warning (ShieldOff icon) when sensitive data is included + * - Different action text based on sensitive data state + * + * @example + * ```svelte + * showExportSettings = false} + * /> + * ``` + */ +export { default as DialogExportSettings } from './DialogExportSettings.svelte'; + +/** + * + * CONFIRMATION DIALOGS + * + * Dialogs for user action confirmations. Use AlertDialog for blocking + * confirmations that require explicit user decision before proceeding. + * + */ + +/** + * **DialogConfirmation** - Generic confirmation dialog + * + * Reusable confirmation dialog with customizable title, description, + * and action buttons. Supports destructive action styling and custom icons. + * Used for delete confirmations, irreversible actions, and important decisions. + * + * **Architecture:** + * - Uses ShadCN AlertDialog + * - Supports variant styling (default, destructive) + * - Customizable button labels and callbacks + * + * **Features:** + * - Customizable title and description text + * - Destructive variant with red styling for dangerous actions + * - Custom icon support in header + * - Cancel and confirm button callbacks + * - Keyboard accessible (Escape to cancel, Enter to confirm) + * + * @example + * ```svelte + * showDelete = false} + * /> + * ``` + */ +export { default as DialogConfirmation } from './DialogConfirmation.svelte'; + +/** + * **DialogConversationTitleUpdate** - Conversation rename confirmation + * + * Confirmation dialog shown when editing the first user message in a conversation. + * Asks user whether to update the conversation title to match the new message content. + * + * **Architecture:** + * - Uses ShadCN AlertDialog + * - Shows current vs proposed title comparison + * - Triggered by ChatMessages when first message is edited + * + * **Features:** + * - Side-by-side display of current and new title + * - "Keep Current Title" and "Update Title" action buttons + * - Styled title previews in muted background boxes + * + * @example + * ```svelte + * showTitleUpdate = false} + * /> + * ``` + */ +export { default as DialogConversationTitleUpdate } from './DialogConversationTitleUpdate.svelte'; + +/** + * + * CONTENT PREVIEW DIALOGS + * + * Dialogs for previewing and displaying content in full-screen or modal views. + * + */ + +/** + * **DialogCodePreview** - Full-screen code/HTML preview + * + * Full-screen dialog for previewing HTML or code in an isolated iframe. + * Used by MarkdownContent component for previewing rendered HTML blocks + * from code blocks in chat messages. + * + * **Architecture:** + * - Uses ShadCN Dialog with full viewport layout + * - Sandboxed iframe execution (allow-scripts only) + * - Clears content when closed for security + * + * **Features:** + * - Full viewport iframe preview + * - Sandboxed execution environment + * - Close button with mix-blend-difference for visibility over any content + * - Automatic content cleanup on close + * - Supports HTML preview with proper isolation + * + * @example + * ```svelte + * + * ``` + */ +export { default as DialogCodePreview } from './DialogCodePreview.svelte'; + +/** + * + * ATTACHMENT DIALOGS + * + * Dialogs for viewing and managing file attachments. Support both + * uploaded files (pending) and stored attachments (in messages). + * + */ + +/** + * **DialogChatAttachmentsPreview** - Unified attachment preview dialog + * + * Modal dialog for previewing file attachments. Automatically adapts to the + * number of items: shows a single file preview without carousel for one item, + * or a gallery with carousel navigation for multiple items. + * + * **Architecture:** + * - Wraps ChatAttachmentsPreview component in ShadCN Dialog + * - Accepts uploadedFiles and attachments arrays as data sources + * - Filters out MCP prompts and MCP resources from display + * + * **Features:** + * - Single item mode: direct preview without navigation controls + * - Multi-item mode: gallery with left/right arrows and thumbnail strip + * - File type aware preview (images, text, PDFs, audio) + * - File name and size/count display in header + * + * @example + * ```svelte + * + * + * ``` + */ +export { default as DialogChatAttachmentsPreview } from './DialogChatAttachmentsPreview.svelte'; + +/** + * + * ERROR & ALERT DIALOGS + * + * Dialogs for displaying errors, warnings, and alerts to users. + * Provide context about what went wrong and recovery options. + * + */ + +/** + * **DialogChatError** - Chat/generation error display + * + * Alert dialog for displaying chat and generation errors with context + * information. Supports different error types with appropriate styling + * and messaging. + * + * **Architecture:** + * - Uses ShadCN AlertDialog for modal display + * - Differentiates between timeout and server errors + * - Shows context info when available (token counts) + * + * **Error Types:** + * - **timeout**: TCP timeout with timer icon, red destructive styling + * - **server**: Server error with warning icon, amber warning styling + * + * **Features:** + * - Type-specific icons (TimerOff for timeout, AlertTriangle for server) + * - Error message display in styled badge + * - Context info showing prompt tokens and context size + * - Close button to dismiss + * + * @example + * ```svelte + * + * ``` + */ +export { default as DialogChatError } from './DialogChatError.svelte'; + +/** + * **DialogEmptyFileAlert** - Empty file upload warning + * + * Alert dialog shown when user attempts to upload empty files. Lists the + * empty files that were detected and removed from attachments, with + * explanation of why empty files cannot be processed. + * + * **Architecture:** + * - Uses ShadCN AlertDialog for modal display + * - Receives list of empty file names from ChatScreen + * - Triggered during file upload validation + * + * **Features:** + * - FileX icon indicating file error + * - List of empty file names in monospace font + * - Explanation of what happened and why + * - Single "Got it" dismiss button + * + * @example + * ```svelte + * + * ``` + */ +export { default as DialogEmptyFileAlert } from './DialogEmptyFileAlert.svelte'; + +/** + * **DialogFileUploadError** - File upload compatibility error + * + * Alert dialog shown when files cannot be uploaded due to type incompatibility + * or model modality restrictions. Displays a categorized list of problematic + * files with explanations and shows which file types the current model supports. + * + * **Architecture:** + * - Uses ShadCN AlertDialog for modal display + * - Receives structured file error data from ChatScreen + * - Triggered during file upload validation in processFiles() + * + * **Features:** + * - Categorized display: unsupported types vs modality restrictions + * - File name in monospace with contextual error messages + * - Summary of supported file types for the current model + * - Scrollable content area for large error lists + * - Single "Got it" dismiss button + * + * @example + * ```svelte + * + * ``` + */ +export { default as DialogFileUploadError } from './DialogFileUploadError.svelte'; + +/** + * **DialogModelNotAvailable** - Model unavailable error + * + * Alert dialog shown when the requested model (from URL params or selection) + * is not available on the server. Displays the requested model name and + * offers selection from available models. + * + * **Architecture:** + * - Uses ShadCN AlertDialog for modal display + * - Integrates with SvelteKit navigation for model switching + * - Receives available models list from modelsStore + * + * **Features:** + * - Warning icon with amber styling + * - Requested model name display in styled badge + * - Scrollable list of available models + * - Click model to navigate with updated URL params + * - Cancel button to dismiss without selection + * + * @example + * ```svelte + * + * ``` + */ +export { default as DialogModelNotAvailable } from './DialogModelNotAvailable.svelte'; + +/** + * + * DATA MANAGEMENT DIALOGS + * + * Dialogs for managing conversation data, including import/export + * and selection operations. + * + */ + +/** + * **DialogConversationSelection** - Conversation picker for import/export + * + * Dialog for selecting conversations during import or export operations. + * Displays list of conversations with checkboxes for multi-selection. + * Used by ChatSettingsImportExportTab for data management. + * + * **Architecture:** + * - Wraps ConversationSelection component in ShadCN Dialog + * - Supports export mode (select from local) and import mode (select from file) + * - Resets selection state when dialog opens + * - High z-index to appear above settings dialog + * + * **Features:** + * - Multi-select with checkboxes + * - Conversation title and message count display + * - Select all / deselect all controls + * - Mode-specific descriptions (export vs import) + * - Cancel and confirm callbacks with selected conversations + * + * @example + * ```svelte + * showExportSelection = false} + * /> + * ``` + */ +export { default as DialogConversationSelection } from './DialogConversationSelection.svelte'; + +/** + * + * MODEL INFORMATION DIALOGS + * + * Dialogs for displaying model and server information. + * + */ + +/** + * **DialogModelInformation** - Model details display + * + * Dialog showing comprehensive information about the currently loaded model + * and server configuration. Displays model metadata, capabilities, and + * server settings in a structured table format. + * + * **Architecture:** + * - Uses ShadCN Dialog with wide layout for table display + * - Fetches data from serverStore (props) and modelsStore (metadata) + * - Auto-fetches models when dialog opens if not loaded + * + * **Information Displayed:** + * - **Model**: Name with copy button + * - **File Path**: Full path to model file with copy button + * - **Context Size**: Current context window size + * - **Training Context**: Original training context (if available) + * - **Model Size**: File size in human-readable format + * - **Parameters**: Parameter count (e.g., "7B", "70B") + * - **Embedding Size**: Embedding dimension + * - **Vocabulary Size**: Token vocabulary size + * - **Vocabulary Type**: Tokenizer type (BPE, etc.) + * - **Parallel Slots**: Number of concurrent request slots + * - **Modalities**: Supported input types (text, vision, audio) + * - **Build Info**: Server build information + * - **Chat Template**: Full Jinja template in scrollable code block + * + * **Features:** + * - Copy buttons for model name and path + * - Modality badges with icons + * - Responsive table layout with container queries + * - Loading state while fetching model info + * - Scrollable chat template display + * + * @example + * ```svelte + * + * ``` + */ +export { default as DialogModelInformation } from './DialogModelInformation.svelte'; + +/** + * **DialogMcpResourcesBrowser** - MCP resources browser dialog + * + * Dialog for browsing and attaching MCP resources to chat context. + * Displays resources from connected MCP servers in a tree structure + * with preview panel and multi-select support. + * + * **Architecture:** + * - Uses ShadCN Dialog with two-panel layout + * - Left panel: McpResourcesBrowser with tree navigation + * - Right panel: McpResourcePreview for selected resource + * - Integrates with mcpStore for resource fetching and attachment + * + * **Features:** + * - Tree-based resource navigation by server and path + * - Single and multi-select with shift+click + * - Resource preview with content display + * - Quick attach button per resource + * - Batch attach for multiple selections + * + * @example + * ```svelte + * + * ``` + */ +export { default as DialogMcpResourcesBrowser } from './DialogMcpResourcesBrowser.svelte'; + +/** + * **DialogMcpResourcePreview** - MCP resource content preview + * + * Dialog for previewing the content of a stored MCP resource attachment. + * Displays the resource content with syntax highlighting for code, + * image rendering for images, and plain text for other content. + * + * **Features:** + * - Syntax highlighted code preview + * - Image rendering for image resources + * - Copy to clipboard and download actions + * - Server name and favicon display + * - MIME type badge + * + * @example + * ```svelte + * + * ``` + */ +export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.svelte'; diff --git a/tools/ui/src/lib/components/app/forms/InputWithSuggestions.svelte b/tools/ui/src/lib/components/app/forms/InputWithSuggestions.svelte new file mode 100644 index 000000000..5d047c59a --- /dev/null +++ b/tools/ui/src/lib/components/app/forms/InputWithSuggestions.svelte @@ -0,0 +1,78 @@ + + +
              + + + onInput(e.currentTarget.value)} + onkeydown={onKeydown} + onblur={onBlur} + onfocus={onFocus} + placeholder="Enter {name}" + autocomplete="off" + /> + + {#if isAutocompleteActive && suggestions.length > 0} +
              + {#each suggestions as suggestion, i (suggestion)} + + {/each} +
              + {/if} +
              diff --git a/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte b/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte new file mode 100644 index 000000000..e0bd8d98e --- /dev/null +++ b/tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte @@ -0,0 +1,143 @@ + + +
              +
              + {#if sectionLabel} + + {sectionLabel} + {#if sectionLabelOptional} + (optional) + {/if} + + {/if} + + +
              + {#if pairs.length > 0} +
              + {#each pairs as pair, index (index)} +
              + updatePairKey(index, e.currentTarget.value)} + onblur={(e) => trimPairKey(index, e.currentTarget.value)} + class="flex-1" + /> + + + + +
              + {/each} +
              + {:else} +

              {emptyMessage}

              + {/if} +
              diff --git a/tools/ui/src/lib/components/app/forms/SearchInput.svelte b/tools/ui/src/lib/components/app/forms/SearchInput.svelte new file mode 100644 index 000000000..19dd7e6a7 --- /dev/null +++ b/tools/ui/src/lib/components/app/forms/SearchInput.svelte @@ -0,0 +1,75 @@ + + +
              + + + + + {#if showClearButton} + + {/if} +
              diff --git a/tools/ui/src/lib/components/app/forms/index.ts b/tools/ui/src/lib/components/app/forms/index.ts new file mode 100644 index 000000000..4cf56cdc9 --- /dev/null +++ b/tools/ui/src/lib/components/app/forms/index.ts @@ -0,0 +1,44 @@ +/** + * + * FORMS & INPUTS + * + * Form-related utility components. + * + */ + +/** + * **InputWithSuggestions** - Input field with autocomplete suggestions + * + * Text input with dropdown suggestions and keyboard navigation. + * Supports autocomplete functionality with suggestion loading. + * + * **Features:** + * - Autocomplete dropdown with suggestions + * - Keyboard navigation (arrow keys, enter) + * - Loading state for suggestions + * - Focus and blur handling + */ +export { default as InputWithSuggestions } from './InputWithSuggestions.svelte'; + +/** + * **KeyValuePairs** - Editable key-value list + * + * Dynamic list of key-value pairs with add/remove functionality. + * Used for HTTP headers, metadata, and configuration. + * + * **Features:** + * - Add new pairs with button + * - Remove individual pairs + * - Customizable placeholders and labels + * - Empty state message + * - Auto-resize value textarea + */ +export { default as KeyValuePairs } from './KeyValuePairs.svelte'; + +/** + * **SearchInput** - Search field with clear button + * + * Input field optimized for search with clear button and keyboard handling. + * Supports placeholder, autofocus, and change callbacks. + */ +export { default as SearchInput } from './SearchInput.svelte'; diff --git a/tools/ui/src/lib/components/app/index.ts b/tools/ui/src/lib/components/app/index.ts new file mode 100644 index 000000000..4914c743a --- /dev/null +++ b/tools/ui/src/lib/components/app/index.ts @@ -0,0 +1,12 @@ +export * from './actions'; +export * from './badges'; +export * from './chat'; +export * from './content'; +export * from './dialogs'; +export * from './forms'; +export * from './mcp'; +export * from './misc'; +export * from './settings'; +export * from './models'; +export * from './navigation'; +export * from './server'; diff --git a/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte b/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte new file mode 100644 index 000000000..2f732cfd5 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte @@ -0,0 +1,89 @@ + + +{#if !hasEnabledMcpServers} + +{:else if mcpFavicons.length > 0} + +{/if} diff --git a/tools/ui/src/lib/components/app/mcp/McpCapabilitiesBadges.svelte b/tools/ui/src/lib/components/app/mcp/McpCapabilitiesBadges.svelte new file mode 100644 index 000000000..d17b24ebb --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpCapabilitiesBadges.svelte @@ -0,0 +1,61 @@ + + +{#if capabilities} + {#if capabilities.server.tools} + + + + Tools + + {/if} + + {#if capabilities.server.resources} + + + + Resources + + {/if} + + {#if capabilities.server.prompts} + + + + Prompts + + {/if} + + {#if capabilities.server.logging} + + + + Logging + + {/if} + + {#if capabilities.server.completions} + + + + Completions + + {/if} + + {#if capabilities.server.tasks} + + + + Tasks + + {/if} +{/if} diff --git a/tools/ui/src/lib/components/app/mcp/McpConnectionLogs.svelte b/tools/ui/src/lib/components/app/mcp/McpConnectionLogs.svelte new file mode 100644 index 000000000..305c9db3a --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpConnectionLogs.svelte @@ -0,0 +1,81 @@ + + +{#if logs.length > 0} + +
              + + {#if isExpanded} + + {:else} + + {/if} + + Connection Log ({logs.length}) + + {#if connectionTimeMs !== undefined} + · Connected in {connectionTimeMs}ms + {/if} + +
              + + +
              + {#each logs as log (log.timestamp.getTime() + log.message)} + {@const IconComponent = getMcpLogLevelIcon(log.level)} + +
              + + {formatTime(log.timestamp)} + + + + + {log.message} +
              + + {#if log.details !== undefined} +
              + details + +
              +{formatLogDetails(log.details)}
              +
              + {/if} + {/each} +
              +
              +
              +{/if} diff --git a/tools/ui/src/lib/components/app/mcp/McpLogo.svelte b/tools/ui/src/lib/components/app/mcp/McpLogo.svelte new file mode 100644 index 000000000..9f73db84d --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpLogo.svelte @@ -0,0 +1,111 @@ + + + diff --git a/tools/ui/src/lib/components/app/mcp/McpResourcePreview.svelte b/tools/ui/src/lib/components/app/mcp/McpResourcePreview.svelte new file mode 100644 index 000000000..55e1e20a2 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpResourcePreview.svelte @@ -0,0 +1,174 @@ + + +
              + {#if !resource} +
              + + + Select a resource to preview +
              + {:else} +
              +
              +

              {resource.title || resource.name}

              + +

              {resource.uri}

              + + {#if resource.description} +

              {resource.description}

              + {/if} +
              + +
              + + + +
              +
              + +
              + {#if isLoading} +
              + +
              + {:else if error} +
              + + + {error} +
              + {:else if content} + {@const textContent = getResourceTextContent(content)} + {@const blobContent = getResourceBlobContent(content)} + + {#if textContent} +
              {textContent}
              + {/if} + + {#each blobContent as blob (blob.uri)} + {#if isImageMimeType(blob.mimeType ?? MimeTypeApplication.OCTET_STREAM)} + Resource content + {:else} +
              + + + Binary content ({blob.mimeType || 'unknown type'}) +
              + {/if} + {/each} + + {#if !textContent && blobContent.length === 0} +
              No content available
              + {/if} + {/if} +
              + + {#if resource.mimeType || resource.annotations} +
              + {#if resource.mimeType} + {resource.mimeType} + {/if} + + {#if resource.annotations?.priority !== undefined} + + Priority: {resource.annotations.priority} + + {/if} + + + Server: {resource.serverName} + +
              + {/if} + {/if} +
              diff --git a/tools/ui/src/lib/components/app/mcp/McpResourceTemplateForm.svelte b/tools/ui/src/lib/components/app/mcp/McpResourceTemplateForm.svelte new file mode 100644 index 000000000..f62632514 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpResourceTemplateForm.svelte @@ -0,0 +1,171 @@ + + +
              + {#each variables as variable (variable.name)} + handleArgInput(variable.name, value)} + onKeydown={(e) => handleArgKeydown(e, variable.name)} + onBlur={() => handleArgBlur(variable.name)} + onFocus={() => handleArgFocus(variable.name)} + onSelectSuggestion={(value) => selectSuggestion(variable.name, value)} + /> + {/each} + + {#if isComplete} +
              +

              Resolved URI:

              + +

              {expandedUri}

              +
              + {/if} + +
              + + + +
              + diff --git a/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowser.svelte b/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowser.svelte new file mode 100644 index 000000000..24538e8d7 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowser.svelte @@ -0,0 +1,153 @@ + + +
              + (searchQuery = q)} + {searchQuery} + /> + +
              + {#if filteredResources.size === 0} + + {:else} + {#each [...filteredResources.entries()] as [serverName, serverRes] (serverName)} + toggleServer(serverName as string)} + onToggleFolder={toggleFolder} + {onSelect} + {onToggle} + {onTemplateSelect} + {searchQuery} + /> + {/each} + {/if} +
              +
              diff --git a/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserEmptyState.svelte b/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserEmptyState.svelte new file mode 100644 index 000000000..4fb0c1e24 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserEmptyState.svelte @@ -0,0 +1,15 @@ + + +
              + {#if isLoading} + Loading resources... + {:else} + No resources available + {/if} +
              diff --git a/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserHeader.svelte b/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserHeader.svelte new file mode 100644 index 000000000..419654c13 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserHeader.svelte @@ -0,0 +1,41 @@ + + +
              +
              + onSearch?.(value)} + /> + + +
              + +

              Available resources

              +
              diff --git a/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserServerItem.svelte b/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserServerItem.svelte new file mode 100644 index 000000000..9acd101cd --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserServerItem.svelte @@ -0,0 +1,230 @@ + + +{#snippet renderTreeNode(node: ResourceTreeNode, depth: number, parentPath: string)} + {@const isFolder = !node.resource && node.children.size > 0} + {@const folderId = `${serverName}:${parentPath}/${node.name}`} + {@const isFolderExpanded = expandedFolders.has(folderId)} + + {#if isFolder} + {@const folderCount = countTreeResources(node)} + onToggleFolder(folderId)}> + + {#if isFolderExpanded} + + {:else} + + {/if} + + + + {node.name} + + ({folderCount}) + + + +
              + {#each sortTreeChildren( [...node.children.values()] ) as child (child.resource?.uri || `${serverName}:${parentPath}/${node.name}/${child.name}`)} + {@render renderTreeNode(child, depth + 1, `${parentPath}/${node.name}`)} + {/each} +
              +
              +
              + {:else if node.resource} + {@const resource = node.resource} + {@const ResourceIcon = getResourceIcon(resource.mimeType, resource.uri)} + {@const isSelected = isResourceSelected(resource)} + {@const resourceDisplayName = resource.title || getDisplayName(node.name)} + +
              + {#if onToggle} + + handleCheckboxChange(resource, checked === true)} + class="h-4 w-4" + /> + {/if} + + +
              + {/if} +{/snippet} + + + + {#if isExpanded} + + {:else} + + {/if} + + +
              + +
              + + + ({serverRes.resources.length} resource{serverRes.resources.length !== 1 + ? 's' + : ''}{#if hasTemplates}, {serverRes.templates.length} template{serverRes.templates + .length !== 1 + ? 's' + : ''}{/if}) + +
              + + {#if serverRes.loading} + + {/if} +
              + + +
              + {#if serverRes.error} +
              + Error: {serverRes.error} +
              + {:else if !hasContent} +
              No resources
              + {:else} + {#if hasResources} + {#each sortTreeChildren( [...resourceTree.children.values()] ) as child (child.resource?.uri || `${serverName}:${child.name}`)} + {@render renderTreeNode(child, 1, '')} + {/each} + {/if} + + {#if hasTemplates && onTemplateSelect} + {#if hasResources} +
              + {/if} + +
              + Templates +
              + + {#each templateInfos as template (template.uriTemplate)} + + {/each} + {/if} + {/if} +
              +
              +
              diff --git a/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/mcp-resources-browser.ts b/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/mcp-resources-browser.ts new file mode 100644 index 000000000..804fa7fe2 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/mcp-resources-browser.ts @@ -0,0 +1,118 @@ +import type { MCPResource, MCPResourceInfo } from '$lib/types'; +import { parseResourcePath } from '$lib/utils'; + +export interface ResourceTreeNode { + name: string; + resource?: MCPResourceInfo; + children: Map; + isFiltered?: boolean; +} + +function resourceMatchesSearch(resource: MCPResource, query: string): boolean { + return ( + resource.title?.toLowerCase().includes(query) || resource.uri.toLowerCase().includes(query) + ); +} + +export function buildResourceTree( + resourceList: MCPResource[], + serverName: string, + searchQuery?: string +): ResourceTreeNode { + const root: ResourceTreeNode = { name: 'root', children: new Map() }; + + if (!searchQuery || !searchQuery.trim()) { + for (const resource of resourceList) { + const pathParts = parseResourcePath(resource.uri); + let current = root; + + for (let i = 0; i < pathParts.length - 1; i++) { + const part = pathParts[i]; + if (!current.children.has(part)) { + current.children.set(part, { name: part, children: new Map() }); + } + current = current.children.get(part)!; + } + + const fileName = pathParts[pathParts.length - 1] || resource.name; + current.children.set(resource.uri, { + name: fileName, + resource: { ...resource, serverName }, + children: new Map() + }); + } + + return root; + } + + const query = searchQuery.toLowerCase(); + + // Build tree with filtering + for (const resource of resourceList) { + if (!resourceMatchesSearch(resource, query)) continue; + + const pathParts = parseResourcePath(resource.uri); + let current = root; + + for (let i = 0; i < pathParts.length - 1; i++) { + const part = pathParts[i]; + if (!current.children.has(part)) { + current.children.set(part, { name: part, children: new Map(), isFiltered: true }); + } + current = current.children.get(part)!; + } + + const fileName = pathParts[pathParts.length - 1] || resource.name; + + current.children.set(resource.uri, { + name: fileName, + resource: { ...resource, serverName }, + children: new Map(), + isFiltered: true + }); + } + + function cleanupEmptyFolders(node: ResourceTreeNode): boolean { + if (node.resource) return true; + + const toDelete: string[] = []; + for (const [name, child] of node.children.entries()) { + if (!cleanupEmptyFolders(child)) { + toDelete.push(name); + } + } + + for (const name of toDelete) { + node.children.delete(name); + } + + return node.children.size > 0; + } + + cleanupEmptyFolders(root); + + return root; +} + +export function countTreeResources(node: ResourceTreeNode): number { + if (node.resource) return 1; + let count = 0; + + for (const child of node.children.values()) { + count += countTreeResources(child); + } + + return count; +} + +export function sortTreeChildren(children: ResourceTreeNode[]): ResourceTreeNode[] { + return children.sort((a, b) => { + const aIsFolder = !a.resource && a.children.size > 0; + const bIsFolder = !b.resource && b.children.size > 0; + + if (aIsFolder && !bIsFolder) return -1; + if (!aIsFolder && bIsFolder) return 1; + + return a.name.localeCompare(b.name); + }); +} diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte new file mode 100644 index 000000000..199cb1458 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte @@ -0,0 +1,192 @@ + + + + {#if isEditing} + + {:else} + + + {#if isError && errorMessage} +

              {errorMessage}

              + {/if} + + {#if isConnected && serverInfo?.description} +

              + {serverInfo.description} +

              + {/if} + +
              + {#if showSkeleton} +
              +
              + + +
              +
              + + + +
              +
              + +
              +
              + + +
              +
              + {:else} + {#if isConnected && instructions} + + {/if} + + {#if tools.length > 0} + + {/if} + + {#if connectionLogs.length > 0} + + {/if} + {/if} +
              + +
              + {#if showSkeleton} + + {:else if protocolVersion} +
              + + Protocol version: {protocolVersion} + +
              + {/if} + + +
              + {/if} +
              + + (showDeleteDialog = open)} + onConfirm={onDelete} +/> diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardActions.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardActions.svelte new file mode 100644 index 000000000..6f137fa21 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardActions.svelte @@ -0,0 +1,40 @@ + + +
              + + + + + +
              diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardDeleteDialog.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardDeleteDialog.svelte new file mode 100644 index 000000000..8f650148a --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardDeleteDialog.svelte @@ -0,0 +1,36 @@ + + + + + + Delete Server + + + Are you sure you want to delete {displayName}? This action cannot be + undone. + + + + + Cancel + + + Delete + + + + diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte new file mode 100644 index 000000000..6727a9000 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte @@ -0,0 +1,64 @@ + + +
              +

              Configure Server

              + + (editUrl = v)} + onHeadersChange={(v) => (editHeaders = v)} + onUseProxyChange={(v) => (editUseProxy = v)} + urlError={editUrl ? urlError : null} + id={serverId} + /> + +
              + + + +
              +
              diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardHeader.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardHeader.svelte new file mode 100644 index 000000000..5544bcec4 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardHeader.svelte @@ -0,0 +1,70 @@ + + +
              +
              +
              +
              + +
              + + {#if capabilities || transportType} +
              + {#if transportType} + {@const TransportIcon = MCP_TRANSPORT_ICONS[transportType]} + + {#if TransportIcon} + + {/if} + + {MCP_TRANSPORT_LABELS[transportType] || transportType} + + {/if} + + {#if capabilities} + + {/if} +
              + {/if} +
              + +
              + +
              +
              +
              diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardToolsList.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardToolsList.svelte new file mode 100644 index 000000000..d0397c17a --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardToolsList.svelte @@ -0,0 +1,47 @@ + + + + + {#if isExpanded} + + {:else} + + {/if} + + {toolsCount} tools available · Show details + + + +
              + {#each tools as tool (tool.name)} +
              + {tool.name} + + {#if tool.description} +

              {tool.description}

              + {/if} +
              + {/each} +
              +
              +
              diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCardSkeleton.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCardSkeleton.svelte new file mode 100644 index 000000000..39a137280 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpServerCardSkeleton.svelte @@ -0,0 +1,34 @@ + + + +
              +
              + + + +
              + +
              + +
              + + + +
              + +
              + + +
              + + + +
              + + + +
              +
              diff --git a/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte b/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte new file mode 100644 index 000000000..79738e30d --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte @@ -0,0 +1,111 @@ + + +
              +
              + + + onUrlChange(e.currentTarget.value)} + class={urlError ? 'border-destructive' : ''} + /> + + {#if urlError} +

              {urlError}

              + {/if} + + {#if !isWebSocket && onUseProxyChange} + + {/if} +
              + + +
              diff --git a/tools/ui/src/lib/components/app/mcp/McpServerIdentity.svelte b/tools/ui/src/lib/components/app/mcp/McpServerIdentity.svelte new file mode 100644 index 000000000..feafc5d81 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpServerIdentity.svelte @@ -0,0 +1,67 @@ + + + + {#if faviconUrl} + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + {/if} + + + + {#if showVersion && serverInfo?.version} + + + + {/if} + + {#if showWebsite && safeWebsiteUrl} + e.stopPropagation()} + > + + + {/if} + diff --git a/tools/ui/src/lib/components/app/mcp/McpServerInfo.svelte b/tools/ui/src/lib/components/app/mcp/McpServerInfo.svelte new file mode 100644 index 000000000..aecae6e57 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/McpServerInfo.svelte @@ -0,0 +1,35 @@ + + +{#if instructions} + + + {#if isExpanded} + + {:else} + + {/if} + + Server instructions + + + +

              + {instructions} +

              +
              +
              +{/if} diff --git a/tools/ui/src/lib/components/app/mcp/index.ts b/tools/ui/src/lib/components/app/mcp/index.ts new file mode 100644 index 000000000..3d30bb3b4 --- /dev/null +++ b/tools/ui/src/lib/components/app/mcp/index.ts @@ -0,0 +1,254 @@ +/** + * + * MCP (Model Context Protocol) + * + * Components for managing MCP server connections and displaying server status. + * MCP enables agentic workflows by connecting to external tool servers. + * + * The MCP system integrates with: + * - `mcpStore` for server CRUD operations and health checks + * - `conversationsStore` for per-conversation server enable/disable + * + */ + +/** + * **McpServersSettings** - MCP servers configuration section + * + * Settings section for configuring MCP server connections. + * Displays server cards with status, tools, and management actions. + * Used within the MCP tab of ChatSettings. + * + * **Architecture:** + * - Manages add server form state locally + * - Delegates server display to McpServerCard components + * - Integrates with mcpStore for server operations + * - Shows skeleton loading states during health checks + * + * **Features:** + * - Add new MCP servers by URL with validation + * - Server cards with connection status indicators + * - Health check status (connected/disconnected/error) + * - Tools list per server showing available capabilities + * - Enable/disable toggle per conversation + * - Edit/delete server actions + * - Skeleton loading states during connection + * - Empty state with helpful message + * + * @example + * ```svelte + * + * ``` + */ +export { default as McpServersSettings } from '../settings/SettingsMcpServers.svelte'; + +/** + * **McpActiveServersAvatars** - Active MCP servers indicator + * + * Compact avatar row showing favicons of active MCP servers. + * Displays up to 3 server icons with "+N" counter for additional servers. + * Clickable to open MCP settings dialog. + * + * **Architecture:** + * - Filters servers by enabled status and health check + * - Fetches favicons from server URLs + * - Integrates with conversationsStore for per-chat server state + * + * **Features:** + * - Overlapping favicon avatars (max 3 visible) + * - "+N" counter for additional servers + * - Click handler for settings navigation + * - Disabled state support + * - Only shows healthy, enabled servers + * + * @example + * ```svelte + * showMcpSettings = true} + * /> + * ``` + */ +export { default as McpActiveServersAvatars } from './McpActiveServersAvatars.svelte'; + +/** + * **McpCapabilitiesBadges** - Server capabilities display + * + * Displays MCP server capabilities as colored badges. + * Shows which features the server supports (tools, resources, prompts, etc.). + * + * **Features:** + * - Tools badge (green) - server provides callable tools + * - Resources badge (blue) - server provides data resources + * - Prompts badge (purple) - server provides prompt templates + * - Logging badge (orange) - server supports logging + * - Completions badge (cyan) - server provides completions + * - Tasks badge (pink) - server supports task management + */ +export { default as McpCapabilitiesBadges } from './McpCapabilitiesBadges.svelte'; + +/** + * **McpConnectionLogs** - Connection log viewer + * + * Collapsible panel showing MCP server connection logs. + * Displays timestamped log entries with level-based styling. + * + * **Features:** + * - Collapsible log list with entry count + * - Connection time display in milliseconds + * - Log level icons and color coding + * - Scrollable log container with max height + * - Monospace font for log readability + */ +export { default as McpConnectionLogs } from './McpConnectionLogs.svelte'; + +/** + * **McpServerForm** - Server URL and headers input form + * + * Reusable form for entering MCP server connection details. + * Used in both add new server and edit server flows. + * + * **Features:** + * - URL input with validation error display + * - Custom headers key-value pairs editor + * - Controlled component with change callbacks + * + * @example + * ```svelte + * serverUrl = v} + * onHeadersChange={(v) => serverHeaders = v} + * urlError={validationError} + * /> + * ``` + */ +export { default as McpServerForm } from './McpServerForm.svelte'; + +/** + * MCP protocol logo SVG component. Renders the official MCP icon + * with customizable size via class and style props. + */ +export { default as McpLogo } from './McpLogo.svelte'; + +/** + * + * SERVER CARD + * + * Components for displaying individual MCP server status and controls. + * McpServerCard is the main component, with sub-components for specific sections. + * + */ + +/** + * **McpServerCard** - Individual server display card + * + * Main component for displaying a single MCP server with all its details. + * Manages edit mode, delete confirmation, and health check actions. + * + * **Architecture:** + * - Composes header, tools list, logs, and actions sub-components + * - Manages local edit/delete state + * - Reads health state from mcpStore + * - Triggers health checks via mcpStore + * + * **Features:** + * - Server header with favicon, name, version, and toggle + * - Capabilities badges display + * - Tools list with descriptions + * - Connection logs viewer + * - Edit form for URL and headers + * - Delete confirmation dialog + * - Skeleton loading states + */ +export { default as McpServerCard } from './McpServerCard/McpServerCard.svelte'; + +/** Server card header with favicon, name, version badge, and enable toggle. */ +export { default as McpServerCardHeader } from './McpServerCard/McpServerCardHeader.svelte'; + +/** Action buttons row: edit, refresh, delete. */ +export { default as McpServerCardActions } from './McpServerCard/McpServerCardActions.svelte'; + +/** Collapsible tools list showing available server tools with descriptions. */ +export { default as McpServerCardToolsList } from './McpServerCard/McpServerCardToolsList.svelte'; + +/** Inline edit form for server URL and custom headers. */ +export { default as McpServerCardEditForm } from './McpServerCard/McpServerCardEditForm.svelte'; + +/** Delete confirmation dialog with server name display. */ +export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerCardDeleteDialog.svelte'; + +/** Skeleton loading state for server card during health checks. */ +export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte'; + +/** + * **McpServerIdentity** - Server identity display (icon, name, version) + * + * Reusable headless component for displaying server name, favicon/icon, and version badge. + * Accepts all data via props with no store dependencies for predictable rendering. + * + * **Features:** + * - Server favicon/icon with fallback + * - Truncated display name with max-width + * - Optional version badge (v1.2.3) + * - Optional external link to server website + * + * @example + * ```svelte + * + * ``` + */ +export { default as McpServerIdentity } from './McpServerIdentity.svelte'; + +/** + * **McpServerInfo** - Server instructions display + * + * Collapsible panel showing server-provided instructions. + * Displays guidance text from the MCP server for users. + */ +export { default as McpServerInfo } from './McpServerInfo.svelte'; + +/** + * **McpResourcesBrowser** - MCP resources tree browser + * + * Tree view component showing resources grouped by server. + * Supports resource selection and quick attach actions. + * + * **Features:** + * - Collapsible server sections + * - Resource icons based on MIME type + * - Resource selection highlighting + * - Quick attach button per resource + * - Refresh all resources action + * - Loading states per server + */ +export { default as McpResourcesBrowser } from './McpResourcesBrowser/McpResourcesBrowser.svelte'; + +/** + * **McpResourcePreview** - MCP resource content preview + * + * Preview panel showing resource content with metadata. + * Supports text and binary content display. + * + * **Features:** + * - Text content display with monospace formatting + * - Image preview for image MIME types + * - Copy to clipboard action + * - Download content action + * - Resource metadata display (MIME type, priority, server) + * - Loading and error states + */ +export { default as McpResourcePreview } from './McpResourcePreview.svelte'; + +/** + * **McpResourceTemplateForm** - MCP resource template variable form + * + * Form for filling in resource template variables with auto-completion + * via the Completions API. Shows live URI preview as variables are filled. + * + * **Features:** + * - Template variable input fields + * - Completions API integration for variable auto-complete + * - Live URI preview as variables are filled + * - Read resolved resource action + */ +export { default as McpResourceTemplateForm } from './McpResourceTemplateForm.svelte'; diff --git a/tools/ui/src/lib/components/app/misc/CodeBlockActions.svelte b/tools/ui/src/lib/components/app/misc/CodeBlockActions.svelte new file mode 100644 index 000000000..fa12d1c62 --- /dev/null +++ b/tools/ui/src/lib/components/app/misc/CodeBlockActions.svelte @@ -0,0 +1,33 @@ + + +
              + + + {#if showPreview} + onPreview!(code, language)} + /> + {/if} +
              diff --git a/tools/ui/src/lib/components/app/misc/ConversationSelection.svelte b/tools/ui/src/lib/components/app/misc/ConversationSelection.svelte new file mode 100644 index 000000000..db14fd631 --- /dev/null +++ b/tools/ui/src/lib/components/app/misc/ConversationSelection.svelte @@ -0,0 +1,194 @@ + + +
              + + +
              + + {selectedIds.size} of {conversations.length} selected + {#if searchQuery} + ({filteredConversations.length} shown) + {/if} + +
              + +
              + + + + + + + + + + + + + {#if filteredConversations.length === 0} + + + + {:else} + {#each filteredConversations as conv (conv.id)} + toggleConversation(conv.id, event.shiftKey)} + > + + + + + + + {/each} + {/if} + +
              + + Conversation NameMessages
              + {#if searchQuery} + No conversations found matching "{searchQuery}" + {:else} + No conversations available + {/if} +
              + { + event.preventDefault(); + event.stopPropagation(); + toggleConversation(conv.id, event.shiftKey); + }} + /> + +
              + {conv.name || 'Untitled conversation'} +
              +
              + {messageCountMap.get(conv.id) ?? 0} +
              +
              +
              + +
              + + + +
              +
              diff --git a/tools/ui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte b/tools/ui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte new file mode 100644 index 000000000..06d0e3a05 --- /dev/null +++ b/tools/ui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte @@ -0,0 +1,94 @@ + + +
              + + +
              + {@render children?.()} +
              + + +
              diff --git a/tools/ui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte b/tools/ui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte new file mode 100644 index 000000000..da55abda0 --- /dev/null +++ b/tools/ui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte @@ -0,0 +1,33 @@ + + + + {#each keys as key, index (index)} + {#if key === 'shift'} + + {:else if key === 'cmd'} + ⌘ + {:else} + {key.toUpperCase()} + {/if} + + {#if index < keys.length - 1} + + {/if} + {/each} + diff --git a/tools/ui/src/lib/components/app/misc/TruncatedText.svelte b/tools/ui/src/lib/components/app/misc/TruncatedText.svelte new file mode 100644 index 000000000..a6b7cb483 --- /dev/null +++ b/tools/ui/src/lib/components/app/misc/TruncatedText.svelte @@ -0,0 +1,49 @@ + + +{#if isTruncated && showTooltip} + + + + {text} + + + + +

              {text}

              +
              +
              +{:else} + + {text} + +{/if} diff --git a/tools/ui/src/lib/components/app/misc/index.ts b/tools/ui/src/lib/components/app/misc/index.ts new file mode 100644 index 000000000..64b76fb71 --- /dev/null +++ b/tools/ui/src/lib/components/app/misc/index.ts @@ -0,0 +1,53 @@ +/** + * + * MISC + * + * Miscellaneous utility components. + * + */ + +/** + * **ConversationSelection** - Multi-select conversation picker + * + * List of conversations with checkboxes for multi-selection. + * Used in import/export dialogs for selecting conversations. + * + * **Features:** + * - Search/filter conversations by name + * - Select all / deselect all controls + * - Shift-click for range selection + * - Message count display per conversation + * - Mode-specific UI (export vs import) + */ +export { default as ConversationSelection } from './ConversationSelection.svelte'; + +/** + * Horizontal scrollable carousel with navigation arrows. + * Used for displaying items in a horizontally scrollable container + * with left/right navigation buttons that appear on hover. + */ +export { default as HorizontalScrollCarousel } from './HorizontalScrollCarousel.svelte'; + +/** + * **TruncatedText** - Text with ellipsis and tooltip + * + * Displays text with automatic truncation and full content in tooltip. + * Useful for long names or paths in constrained spaces. + */ +export { default as TruncatedText } from './TruncatedText.svelte'; + +/** + * **KeyboardShortcutInfo** - Keyboard shortcut hint display + * + * Displays keyboard shortcut hints (e.g., "⌘ + Enter"). + * Supports special keys like shift, cmd, and custom text. + */ +export { default as KeyboardShortcutInfo } from './KeyboardShortcutInfo.svelte'; + +/** + * **CodeBlockActions** - Actions bar for code blocks (copy, preview) + * + * Displays copy-to-clipboard and preview buttons for code blocks. + * Preview button is shown only for HTML code blocks. + */ +export { default as CodeBlockActions } from './CodeBlockActions.svelte'; diff --git a/tools/ui/src/lib/components/app/models/ModelBadge.svelte b/tools/ui/src/lib/components/app/models/ModelBadge.svelte new file mode 100644 index 000000000..cc1d1848e --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelBadge.svelte @@ -0,0 +1,60 @@ + + +{#snippet badgeContent()} + + {#snippet icon()} + + {/snippet} + + {#if model} + + {/if} + + {#if showCopyIcon} + + {/if} + +{/snippet} + +{#if shouldShow} + {#if showTooltip} + + + {@render badgeContent()} + + + + {onclick ? 'Click for model details' : model} + + + {:else} + {@render badgeContent()} + {/if} +{/if} diff --git a/tools/ui/src/lib/components/app/models/ModelId.svelte b/tools/ui/src/lib/components/app/models/ModelId.svelte new file mode 100644 index 000000000..2fe952fee --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelId.svelte @@ -0,0 +1,78 @@ + + +{#if resolvedShowRaw} + +{:else} + + + {#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName} + + + {#if parsed.params} + + {parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''} + + {/if} + + {#if parsed.quantization && !hideQuantization} + + {parsed.quantization} + + {/if} + + {#if primaryAlias} + {#if primaryAlias !== parsed.modelName} + {parsed.modelName ?? modelId} + {/if} + {:else if uniqueAliases.length > 1} + {#each uniqueAliases as alias (alias)} + {alias} + {/each} + {/if} + + {#if uniqueTags.length > 0} + {#each uniqueTags as tag (tag)} + {tag} + {/each} + {/if} + +{/if} diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte new file mode 100644 index 000000000..998a6a0fa --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte @@ -0,0 +1,290 @@ + + +
              + {#if ms.loading && ms.options.length === 0 && ms.isRouter} +
              + + + Loading models… +
              + {:else if ms.options.length === 0 && ms.isRouter} + {#if currentModel} + + + + + + {:else} +

              No models available.

              + {/if} + {:else} + {@const selectedOption = ms.getDisplayOption()} + + {#if ms.isRouter} + + + + + {#if selectedOption} + + + + {#snippet child({ props })} + + {/snippet} + + + +

              {selectedOption.model}

              +
              +
              + {:else} + Select model + {/if} + + {#if ms.updating || ms.isLoadingModel} + + {:else} + + {/if} +
              + + + ms.setSearchTerm(v)} + placeholder="Search models..." + onSearchKeyDown={handleSearchKeyDown} + emptyMessage="No models found." + isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache} + > +
              + {#if !ms.isCurrentModelInCache && currentModel} + + + {/if} + + {#if ms.filteredOptions.length === 0} +

              No models found.

              + {/if} + + {#snippet modelOption(item: ModelItem, hideOrgName: boolean)} + {@const { option, flatIndex } = item} + {@const isSelected = currentModel === option.model || ms.activeId === option.id} + {@const isHighlighted = flatIndex === highlightedIndex} + {@const isFav = ms.isFavorite(option.model)} + + (highlightedIndex = flatIndex)} + onKeyDown={(event) => { + if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) { + event.preventDefault(); + ms.handleSelect(option.id); + } + }} + /> + {/snippet} + + +
              +
              +
              +
              + {:else} + + {/if} + {/if} +
              + +{#if ms.showModelDialog} + ms.setShowModelDialog(v)} + modelId={ms.infoModelId} + /> +{/if} diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte new file mode 100644 index 000000000..61a4cf0f6 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte @@ -0,0 +1,72 @@ + + +{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)} + {@const { option } = item} + {@const isSelected = currentModel === option.model || activeId === option.id} + {@const isFav = modelsStore.favoriteModelIds.has(option.model)} + + {}} + onKeyDown={() => {}} + /> +{/snippet} + +{#if groups.loaded.length > 0} +

              Loaded models

              + {#each groups.loaded as item (`loaded-${item.option.id}`)} + {@render render(item, false)} + {/each} +{/if} + +{#if groups.favorites.length > 0} +

              Favorite models

              + {#each groups.favorites as item (`fav-${item.option.id}`)} + {@render render(item, true)} + {/each} +{/if} + +{#if groups.available.length > 0} +

              Available models

              + {#each groups.available as group (group.orgName)} + {#if group.orgName} +

              {group.orgName}

              + {/if} + {#each group.items as item (item.option.id)} + {@render render(item, true)} + {/each} + {/each} +{/if} diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte new file mode 100644 index 000000000..d103d4b67 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte @@ -0,0 +1,181 @@ + + +
              onSelect(option.id)} + onmouseenter={onMouseEnter} + onkeydown={onKeyDown} +> + + +
              + + +
              e.stopPropagation()} + > + {#if isFav} + modelsStore.toggleFavorite(option.model)} + /> + {:else} + modelsStore.toggleFavorite(option.model)} + /> + {/if} + + + {#if isLoaded && onInfoClick} + onInfoClick(option.model)} + /> + {/if} +
              + + {#if isLoading} + + {:else if isFailed} +
              + + + +
              + {:else if isSleeping} +
              + + + +
              + {:else if isLoaded} +
              + + + +
              + {:else} +
              + + + +
              + {/if} +
              +
              diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte new file mode 100644 index 000000000..d38ed8c07 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte @@ -0,0 +1,189 @@ + + +
              + {#if ms.loading && ms.options.length === 0 && ms.isRouter} +
              + + Loading models… +
              + {:else if ms.options.length === 0 && ms.isRouter} +

              No models available.

              + {:else} + {@const selectedOption = ms.getDisplayOption()} + + {#if ms.isRouter} + + + + + + Select Model + + + Choose a model to use for the conversation + + + +
              +
              + ms.setSearchTerm(v)} + /> +
              + +
              + {#if !ms.isCurrentModelInCache && currentModel} + +
              + {/if} + + {#if ms.filteredOptions.length === 0} +

              No models found.

              + {/if} + + +
              +
              +
              +
              + {:else} + + {/if} + {/if} +
              + +{#if ms.showModelDialog} + ms.setShowModelDialog(v)} + modelId={ms.infoModelId} + /> +{/if} diff --git a/tools/ui/src/lib/components/app/models/index.ts b/tools/ui/src/lib/components/app/models/index.ts new file mode 100644 index 000000000..a6ba6817f --- /dev/null +++ b/tools/ui/src/lib/components/app/models/index.ts @@ -0,0 +1,112 @@ +/** + * + * MODELS + * + * Components for model selection and display. Supports two server modes: + * - **Single model mode**: Server runs with one model, selector shows model info + * - **Router mode**: Server runs with multiple models, selector enables switching + * + * Integrates with modelsStore for model data and serverStore for mode detection. + * + */ + +/** + * **ModelsSelectorDropdown** - Model selection dropdown (desktop) + * + * Dropdown for selecting AI models with status indicators, + * search, and model information display. Adapts UI based on server mode. + * + * **Architecture:** + * - Uses DropdownMenuSearchable for model list + * - Integrates with modelsStore for model options and selection + * - Detects router vs single mode from serverStore + * - Opens DialogModelInformation for model details + * + * **Features:** + * - Searchable model list with keyboard navigation + * - Model status indicators (loading/ready/error/updating) + * - Model capabilities badges (vision, tools, etc.) + * - Current/active model highlighting + * - Model information dialog on info button click + * - Router mode: shows all available models with status + * - Single mode: shows current model name only + * - Loading/updating skeleton states + * - Global selection support for form integration + * + * @example + * ```svelte + * updateModel(id)} + * useGlobalSelection + * /> + * ``` + */ +export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte'; + +/** + * **ModelsSelectorList** - Grouped model options list + * + * Renders grouped model options (loaded, favorites, available) with section + * headers and org subgroups. Shared between ModelsSelectorDropdown and ModelsSelectorSheet + * to avoid template duplication. + * + * Accepts an optional `renderOption` snippet to customize how each option is + * rendered (e.g., to add keyboard navigation or highlighting). + */ +export { default as ModelsSelectorList } from './ModelsSelectorList.svelte'; + +/** + * **ModelsSelectorOption** - Single model option row + * + * Renders a single model option with selection state, favorite toggle, + * load/unload actions, status indicators, and an info button. + * Used inside ModelsSelectorList or directly in custom render snippets. + */ +export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte'; + +/** + * **ModelsSelectorSheet** - Mobile model selection sheet + * + * Bottom sheet variant of ModelsSelectorDropdown optimized for touch interaction + * on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI + * instead of DropdownMenu. + */ +export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte'; + +/** + * **ModelBadge** - Model name display badge + * + * Compact badge showing current model name with package icon. + * Only visible in single model mode. Supports tooltip and copy functionality. + * + * **Architecture:** + * - Reads model name from modelsStore or prop + * - Checks server mode from serverStore + * - Uses BadgeInfo for consistent styling + * + * **Features:** + * - Optional copy to clipboard button + * - Optional tooltip with model details + * - Click handler for model info dialog + * - Only renders in model mode (not router) + * + * @example + * ```svelte + * showModelInfo = true} + * showTooltip + * showCopyIcon + * /> + * ``` + */ +export { default as ModelBadge } from './ModelBadge.svelte'; + +/** + * **ModelId** - Parsed model identifier display + * + * Displays a model ID with optional org name, parameter badges, quantization, + * aliases, and tags. Supports raw mode to show the unprocessed model name. + * Respects the user's `showRawModelNames` setting. + */ +export { default as ModelId } from './ModelId.svelte'; diff --git a/tools/ui/src/lib/components/app/models/utils.ts b/tools/ui/src/lib/components/app/models/utils.ts new file mode 100644 index 000000000..ae1f511e9 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/utils.ts @@ -0,0 +1,75 @@ +import { SvelteMap } from 'svelte/reactivity'; +import type { ModelOption } from '$lib/types/models'; + +export interface ModelItem { + option: ModelOption; + flatIndex: number; +} + +export interface OrgGroup { + orgName: string | null; + items: ModelItem[]; +} + +export interface GroupedModelOptions { + loaded: ModelItem[]; + favorites: ModelItem[]; + available: OrgGroup[]; +} + +export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] { + const term = searchTerm.trim().toLowerCase(); + if (!term) return options; + + return options.filter( + (option) => + option.model.toLowerCase().includes(term) || + option.name?.toLowerCase().includes(term) || + option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) || + option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) + ); +} + +export function groupModelOptions( + filteredOptions: ModelOption[], + favoriteIds: Set, + isModelLoaded: (model: string) => boolean +): GroupedModelOptions { + // Loaded models + const loaded: ModelItem[] = []; + for (let i = 0; i < filteredOptions.length; i++) { + if (isModelLoaded(filteredOptions[i].model)) { + loaded.push({ option: filteredOptions[i], flatIndex: i }); + } + } + + // Favorites (excluding loaded) + const loadedModelIds = new Set(loaded.map((item) => item.option.model)); + const favorites: ModelItem[] = []; + for (let i = 0; i < filteredOptions.length; i++) { + if ( + favoriteIds.has(filteredOptions[i].model) && + !loadedModelIds.has(filteredOptions[i].model) + ) { + favorites.push({ option: filteredOptions[i], flatIndex: i }); + } + } + + // Available models grouped by org (excluding loaded and favorites) + const available: OrgGroup[] = []; + const orgGroups = new SvelteMap(); + for (let i = 0; i < filteredOptions.length; i++) { + const option = filteredOptions[i]; + if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue; + + const key = option.parsedId?.orgName ?? ''; + if (!orgGroups.has(key)) orgGroups.set(key, []); + orgGroups.get(key)!.push({ option, flatIndex: i }); + } + + for (const [orgName, items] of orgGroups) { + available.push({ orgName: orgName || null, items }); + } + + return { loaded, favorites, available }; +} diff --git a/tools/ui/src/lib/components/app/navigation/DesktopIconStrip.svelte b/tools/ui/src/lib/components/app/navigation/DesktopIconStrip.svelte new file mode 100644 index 000000000..e92b9528a --- /dev/null +++ b/tools/ui/src/lib/components/app/navigation/DesktopIconStrip.svelte @@ -0,0 +1,84 @@ + + + + + + diff --git a/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte b/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte new file mode 100644 index 000000000..83d856d10 --- /dev/null +++ b/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte @@ -0,0 +1,86 @@ + + + + e.stopPropagation()} + > + {#if triggerTooltip} + + + {@render iconComponent(triggerIcon, 'h-3 w-3')} + {triggerTooltip} + + +

              {triggerTooltip}

              +
              +
              + {:else} + {@render iconComponent(triggerIcon, 'h-3 w-3')} + {/if} +
              + + + {#each actions as action, index (action.label)} + {#if action.separator && index > 0} + + {/if} + + +
              + {@render iconComponent( + action.icon, + `h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}` + )} + {action.label} +
              + + {#if action.shortcut} + + {/if} +
              + {/each} +
              +
              + +{#snippet iconComponent(IconComponent: Component, className: string)} + +{/snippet} diff --git a/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte b/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte new file mode 100644 index 000000000..3bd68d3bd --- /dev/null +++ b/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte @@ -0,0 +1,50 @@ + + +
              + +
              + +
              + {@render children()} + + {#if isEmpty} +
              {emptyMessage}
              + {/if} +
              + +{#if footer} + + + {@render footer()} +{/if} diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte new file mode 100644 index 000000000..ddaf4d5b8 --- /dev/null +++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte @@ -0,0 +1,299 @@ + + +
              + + +
              + +

              + {APP_NAME} +

              +
              + + +
              + + +
              + + + {#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive} + + {isSearchModeActive ? 'Search results' : 'Recent conversations'} + + {/if} + + + + {#each conversationTree as { conversation, depth } (conversation.id)} + + + + {/each} + + {#if conversationTree.length === 0} +
              +

              + {searchQuery.length > 0 + ? 'No results found' + : isSearchModeActive + ? 'Start typing to see results' + : 'No conversations yet'} +

              +
              + {/if} +
              +
              +
              +
              +
              + + { + showDeleteDialog = false; + selectedConversation = null; + }} +> + {#if selectedConversationHasDescendants} +
              + + + +
              + {/if} +
              + + { + showEditDialog = false; + selectedConversation = null; + }} + onKeydown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopImmediatePropagation(); + handleConfirmEdit(); + } + }} +> + + diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte new file mode 100644 index 000000000..f0d63970e --- /dev/null +++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte @@ -0,0 +1,96 @@ + + +{#snippet itemIcon(IconComponent: Component)} + +{/snippet} + +
              + {#if isSearchModeActive} + e.key === 'Escape' && handleSearchModeDeactivate()} + placeholder="Search conversations..." + {isCancelAlwaysVisible} + /> + {:else} + {#each SIDEBAR_ACTIONS_ITEMS as item (item.route)} + {#if !item.route} + + {:else} + + {/if} + {/each} + {/if} +
              diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte new file mode 100644 index 000000000..dad8d954c --- /dev/null +++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte @@ -0,0 +1,227 @@ + + + + + + diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSearch.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSearch.svelte new file mode 100644 index 000000000..afc984702 --- /dev/null +++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSearch.svelte @@ -0,0 +1,19 @@ + + + diff --git a/tools/ui/src/lib/components/app/navigation/index.ts b/tools/ui/src/lib/components/app/navigation/index.ts new file mode 100644 index 000000000..d4ca91459 --- /dev/null +++ b/tools/ui/src/lib/components/app/navigation/index.ts @@ -0,0 +1,138 @@ +/** + * + * NAVIGATION & MENUS + * + * Components for dropdown menus and action selection. + * + */ + +/** + * **DropdownMenuSearchable** - Searchable content for dropdown menus + * + * Renders a search input with filtered content area, empty state, and optional footer. + * Designed to be injected into any dropdown container (DropdownMenu.Content, + * DropdownMenu.SubContent, etc.) without providing its own Root. + * + * **Features:** + * - Search/filter input + * - Keyboard navigation support + * - Custom content and footer via snippets + * - Empty state message + * + * @example + * ```svelte + * + * ... + * + * + * {#each items as item}{/each} + * + * + * + * ``` + */ +export { default as DropdownMenuSearchable } from './DropdownMenuSearchable.svelte'; + +/** + * **DropdownMenuActions** - Multi-action dropdown menu + * + * Dropdown menu for multiple action options with icons and shortcuts. + * Supports destructive variants and keyboard shortcut hints. + * + * **Features:** + * - Configurable trigger icon with tooltip + * - Action items with icons and labels + * - Destructive variant styling + * - Keyboard shortcut display + * - Separator support between groups + * + * @example + * ```svelte + * + * ``` + */ +export { default as DropdownMenuActions } from './DropdownMenuActions.svelte'; + +/** + * **DesktopIconStrip** - Fixed icon strip for desktop sidebar + * + * Vertical icon strip shown on desktop when the sidebar is collapsed. + * Contains navigation shortcuts for new chat, search, MCP, import/export, and settings. + */ +export { default as DesktopIconStrip } from './DesktopIconStrip.svelte'; + +/** + * **SidebarNavigation** - Sidebar with actions menu and conversation list + * + * Collapsible sidebar displaying conversation history with search and + * management actions. Integrates with ShadCN sidebar component for + * consistent styling and mobile responsiveness. + * + * **Architecture:** + * - Uses ShadCN Sidebar.* components for structure + * - Fetches conversations from conversationsStore + * - Manages search state and filtered results locally + * - Handles conversation CRUD operations via conversationsStore + * + * **Navigation:** + * - Click conversation to navigate to `/chat/[id]` + * - New chat button navigates to `/` (root) + * - Active conversation highlighted based on route params + * + * **Conversation Management:** + * - Right-click or menu button for context menu + * - Rename: Opens inline edit dialog + * - Delete: Shows confirmation with conversation preview + * - Delete All: Removes all conversations with confirmation + * + * **Features:** + * - Search/filter conversations by title + * - Conversation list with message previews (first message truncated) + * - Active conversation highlighting + * - Mobile-responsive collapse/expand via ShadCN sidebar + * - New chat button in header + * - Settings button opens DialogChatSettings + * + * **Exported API:** + * - `activateSearchMode()` - Focus search input programmatically + * - `editActiveConversation()` - Open rename dialog for current conversation + * + * @example + * ```svelte + * + * ``` + */ +export { default as SidebarNavigation } from './SidebarNavigation/SidebarNavigation.svelte'; + +/** + * Action buttons for sidebar header. Contains new chat button, settings button, + * and delete all conversations button. Manages dialog states for settings and + * delete confirmation. + */ +export { default as SidebarNavigationActions } from './SidebarNavigation/SidebarNavigationActions.svelte'; + +/** + * Single conversation item in sidebar. Displays conversation title (truncated), + * last message preview, and timestamp. Shows context menu on right-click with + * rename and delete options. Highlights when active (matches current route). + * Handles click to navigate and keyboard accessibility. + */ +export { default as SidebarNavigationConversationItem } from './SidebarNavigation/SidebarNavigationConversationItem.svelte'; + +/** + * Search input for filtering conversations in sidebar. Filters conversation + * list by title as user types. Shows clear button when query is not empty. + * Integrated into sidebar header with proper styling. + */ +export { default as SidebarNavigationSearch } from './SidebarNavigation/SidebarNavigationSearch.svelte'; diff --git a/tools/ui/src/lib/components/app/server/ServerErrorSplash.svelte b/tools/ui/src/lib/components/app/server/ServerErrorSplash.svelte new file mode 100644 index 000000000..4da0d1ddf --- /dev/null +++ b/tools/ui/src/lib/components/app/server/ServerErrorSplash.svelte @@ -0,0 +1,285 @@ + + +
              +
              +
              +
              + +
              + +

              Server Connection Error

              + +

              + {error} +

              +
              + + {#if isAccessDeniedError && !showApiKeyInput} +
              + +
              + {/if} + + {#if showApiKeyInput} +
              +
              + + +
              + + {#if apiKeyState === 'validating'} +
              + +
              + {:else if apiKeyState === 'success'} +
              + +
              + {:else if apiKeyState === 'error'} +
              + +
              + {/if} +
              + {#if apiKeyError} +

              + {apiKeyError} +

              + {/if} + {#if apiKeyState === 'success'} +

              + ✓ API key validated successfully! Connecting... +

              + {/if} +
              +
              + + +
              +
              + {/if} + + {#if showRetry} +
              + +
              + {/if} + + {#if showTroubleshooting} +
              +
              + + Troubleshooting + + +
              +
              +

              Start the llama-server:

              + +
              +

              llama-server -hf ggml-org/gemma-3-4b-it-GGUF

              +
              + +

              or

              + +
              +

              llama-server -m locally-stored-model.gguf

              +
              +
              +
                +
              • Check that the server is accessible at the correct URL
              • + +
              • Verify your network connection
              • + +
              • Check server logs for any error messages
              • +
              +
              +
              +
              + {/if} +
              +
              diff --git a/tools/ui/src/lib/components/app/server/ServerLoadingSplash.svelte b/tools/ui/src/lib/components/app/server/ServerLoadingSplash.svelte new file mode 100644 index 000000000..95fa61e93 --- /dev/null +++ b/tools/ui/src/lib/components/app/server/ServerLoadingSplash.svelte @@ -0,0 +1,32 @@ + + +
              +
              +
              +
              + +
              + +

              Connecting to Server

              + +

              + {message} +

              +
              + +
              + +
              +
              +
              diff --git a/tools/ui/src/lib/components/app/server/ServerStatus.svelte b/tools/ui/src/lib/components/app/server/ServerStatus.svelte new file mode 100644 index 000000000..86a962de1 --- /dev/null +++ b/tools/ui/src/lib/components/app/server/ServerStatus.svelte @@ -0,0 +1,65 @@ + + +
              +
              +
              + + {getStatusText()} +
              + + {#if serverData && !error} + + + + {model || 'Unknown Model'} + + + {#if serverData?.default_generation_settings?.n_ctx} + + ctx: {serverData.default_generation_settings.n_ctx.toLocaleString()} + + {/if} + {/if} + + {#if showActions && error} + + {/if} +
              diff --git a/tools/ui/src/lib/components/app/server/index.ts b/tools/ui/src/lib/components/app/server/index.ts new file mode 100644 index 000000000..39ac5b482 --- /dev/null +++ b/tools/ui/src/lib/components/app/server/index.ts @@ -0,0 +1,80 @@ +/** + * + * SERVER + * + * Components for displaying server connection state and handling + * connection errors. Integrates with serverStore for state management. + * + */ + +/** + * **ServerStatus** - Server connection status indicator + * + * Compact status display showing connection state, model name, + * and context size. Used in headers and loading screens. + * + * **Architecture:** + * - Reads state from serverStore (props, loading, error) + * - Displays model name from modelsStore + * + * **Features:** + * - Status dot: green (connected), yellow (connecting), red (error), gray (unknown) + * - Status text label + * - Model name badge with icon + * - Context size badge + * - Optional error action button + * + * @example + * ```svelte + * + * ``` + */ +export { default as ServerStatus } from './ServerStatus.svelte'; + +/** + * **ServerErrorSplash** - Full-screen connection error display + * + * Blocking error screen shown when server connection fails. + * Provides retry options and API key input for authentication errors. + * + * **Architecture:** + * - Detects access denied errors for API key flow + * - Validates API key against server before saving + * - Integrates with settingsStore for API key persistence + * + * **Features:** + * - Error message display with icon + * - Retry connection button with loading state + * - API key input for authentication errors + * - API key validation with success/error feedback + * - Troubleshooting section with server start commands + * - Animated transitions for UI elements + * + * @example + * ```svelte + * + * ``` + */ +export { default as ServerErrorSplash } from './ServerErrorSplash.svelte'; + +/** + * **ServerLoadingSplash** - Full-screen loading display + * + * Shown during initial server connection. Displays loading animation + * with ServerStatus component for real-time connection state. + * + * **Features:** + * - Animated server icon + * - Customizable loading message + * - Embedded ServerStatus for live updates + * + * @example + * ```svelte + * + * ``` + */ +export { default as ServerLoadingSplash } from './ServerLoadingSplash.svelte'; diff --git a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte new file mode 100644 index 000000000..109c8ff9d --- /dev/null +++ b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte @@ -0,0 +1,175 @@ + + +
              +
              + section.slug === activeSlug} + getHref={getSectionHref ?? + ((section: SettingsSection) => RouterService.settings(section.slug))} + /> + + section.slug === activeSlug} + getHref={getSectionHref ?? + ((section: SettingsSection) => RouterService.settings(section.slug))} + bind:this={mobileHeader} + /> + +
              +
              +
              +
              + +

              {currentSection.title}

              +
              + + {#if currentSection.title === SETTINGS_SECTION_TITLES.TOOLS} + + {:else if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT} + + {:else if currentSection.fields} +
              + +
              + {/if} +
              + +
              +

              Settings are saved in browser's localStorage

              +
              +
              + + +
              +
              +
              diff --git a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte new file mode 100644 index 000000000..3ecf00adc --- /dev/null +++ b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte @@ -0,0 +1,265 @@ + + +{#each fields as field (field.key)} +
              + {#if field.type === SettingsFieldType.INPUT} + {@const currentValue = String(localConfig[field.key] ?? '')} + {@const serverDefault = currentModelParams[field.key]} + {@const isCustomRealTime = (() => { + if (serverDefault == null) return false; + if (currentValue === '') return false; + + const numericInput = parseFloat(currentValue); + const normalizedInput = !isNaN(numericInput) + ? Math.round(numericInput * 1000000) / 1000000 + : currentValue; + const normalizedDefault = + typeof serverDefault === 'number' + ? Math.round(serverDefault * 1000000) / 1000000 + : serverDefault; + + return normalizedInput !== normalizedDefault; + })()} + +
              + + {#if isCustomRealTime} + + {/if} +
              + +
              + { + // Update local config immediately for real-time badge feedback + onConfigChange(field.key, e.currentTarget.value); + }} + placeholder={currentModelParams[field.key] != null + ? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}` + : ''} + class="w-full {isCustomRealTime ? 'pr-8' : ''}" + /> + {#if isCustomRealTime} + + {/if} +
              + {#if field.help || SETTING_CONFIG_INFO[field.key]} +

              + {@html field.help || SETTING_CONFIG_INFO[field.key]} +

              + {/if} + {:else if field.type === SettingsFieldType.TEXTAREA} + {#if field.label} + + {/if} + + diff --git a/tools/ui/src/lib/components/ui/tooltip/index.ts b/tools/ui/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 000000000..273d831e6 --- /dev/null +++ b/tools/ui/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,21 @@ +import { Tooltip as TooltipPrimitive } from 'bits-ui'; +import Trigger from './tooltip-trigger.svelte'; +import Content from './tooltip-content.svelte'; + +const Root = TooltipPrimitive.Root; +const Provider = TooltipPrimitive.Provider; +const Portal = TooltipPrimitive.Portal; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal +}; diff --git a/tools/ui/src/lib/components/ui/tooltip/tooltip-content.svelte b/tools/ui/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 000000000..5b0c76818 --- /dev/null +++ b/tools/ui/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,61 @@ + + +{#snippet tooltipContent()} + + {@render children?.()} + + {#snippet child({ props })} +
              + {/snippet} +
              +
              +{/snippet} + +{#if noPortal} + {@render tooltipContent()} +{:else} + + {@render tooltipContent()} + +{/if} diff --git a/tools/ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/tools/ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 000000000..671d6e220 --- /dev/null +++ b/tools/ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,12 @@ + + + diff --git a/tools/ui/src/lib/components/ui/utils.ts b/tools/ui/src/lib/components/ui/utils.ts new file mode 100644 index 000000000..f92bfcbb3 --- /dev/null +++ b/tools/ui/src/lib/components/ui/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/tools/ui/src/lib/constants/agentic.ts b/tools/ui/src/lib/constants/agentic.ts new file mode 100644 index 000000000..c0575163e --- /dev/null +++ b/tools/ui/src/lib/constants/agentic.ts @@ -0,0 +1,52 @@ +import type { AgenticConfig } from '$lib/types/agentic'; + +export const ATTACHMENT_SAVED_REGEX = /\[Attachment saved: ([^\]]+)\]/; + +export const NEWLINE_SEPARATOR = '\n'; + +export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = { + enabled: true, + maxTurns: 100, + maxToolPreviewLines: 25 +} as const; + +export const REASONING_TAGS = { + START: '', + END: '' +} as const; + +/** + * @deprecated Legacy marker tags - only used for migration of old stored messages. + * New messages use structured fields (reasoningContent, toolCalls, toolCallId). + */ +export const LEGACY_AGENTIC_TAGS = { + TOOL_CALL_START: '<<>>', + TOOL_CALL_END: '<<>>', + TOOL_NAME_PREFIX: '<<>>', + TOOL_ARGS_END: '<<>>', + TAG_SUFFIX: '>>>' +} as const; + +/** + * @deprecated Legacy reasoning tags - only used for migration of old stored messages. + * New messages use the dedicated reasoningContent field. + */ +export const LEGACY_REASONING_TAGS = { + START: '<<>>', + END: '<<>>' +} as const; + +/** + * @deprecated Legacy regex patterns - only used for migration of old stored messages. + */ +export const LEGACY_AGENTIC_REGEX = { + COMPLETED_TOOL_CALL: + /<<>>\n<<>>\n<<>>([\s\S]*?)<<>>([\s\S]*?)<<>>/g, + REASONING_BLOCK: /<<>>[\s\S]*?<<>>/g, + REASONING_EXTRACT: /<<>>([\s\S]*?)<<>>/, + REASONING_OPEN: /<<>>[\s\S]*$/, + AGENTIC_TOOL_CALL_BLOCK: /\n*<<>>[\s\S]*?<<>>/g, + AGENTIC_TOOL_CALL_OPEN: /\n*<<>>[\s\S]*$/, + HAS_LEGACY_MARKERS: /<<<(?:AGENTIC_TOOL_CALL_START|reasoning_content_start)>>>/ +} as const; diff --git a/tools/ui/src/lib/constants/api-endpoints.ts b/tools/ui/src/lib/constants/api-endpoints.ts new file mode 100644 index 000000000..f89ebe421 --- /dev/null +++ b/tools/ui/src/lib/constants/api-endpoints.ts @@ -0,0 +1,13 @@ +export const API_MODELS = { + LIST: '/v1/models', + LOAD: '/models/load', + UNLOAD: '/models/unload' +}; + +export const API_TOOLS = { + LIST: '/tools', + EXECUTE: '/tools' +}; + +/** CORS proxy endpoint path */ +export const CORS_PROXY_ENDPOINT = '/cors-proxy'; diff --git a/tools/ui/src/lib/constants/attachment-labels.ts b/tools/ui/src/lib/constants/attachment-labels.ts new file mode 100644 index 000000000..be9999c0f --- /dev/null +++ b/tools/ui/src/lib/constants/attachment-labels.ts @@ -0,0 +1,4 @@ +export const ATTACHMENT_LABEL_FILE = 'File'; +export const ATTACHMENT_LABEL_PDF_FILE = 'PDF File'; +export const ATTACHMENT_LABEL_MCP_PROMPT = 'MCP Prompt'; +export const ATTACHMENT_LABEL_MCP_RESOURCE = 'MCP Resource'; diff --git a/tools/ui/src/lib/constants/attachment-menu.ts b/tools/ui/src/lib/constants/attachment-menu.ts new file mode 100644 index 000000000..dea4d1a39 --- /dev/null +++ b/tools/ui/src/lib/constants/attachment-menu.ts @@ -0,0 +1,103 @@ +import type { Component } from 'svelte'; +import { MessageSquare, Zap, FolderOpen } from '@lucide/svelte'; +import { FILE_TYPE_ICONS } from '$lib/constants/icons'; +import { + AttachmentAction, + AttachmentItemEnabledWhen, + AttachmentItemVisibleWhen, + AttachmentMenuItemId +} from '$lib/enums'; + +export interface AttachmentMenuItem { + /** Unique identifier for the item */ + id: AttachmentMenuItemId; + /** Display label */ + label: string; + /** Lucide icon component */ + icon: Component; + /** Extra CSS class applied to the item (e.g. for test selectors) */ + class?: string; + /** Whether the item requires a specific modality to be enabled */ + enabledWhen?: AttachmentItemEnabledWhen; + /** Tooltip shown when the item is disabled */ + disabledTooltip?: string; + /** Callback key on the Props interface to invoke when clicked */ + action: AttachmentAction; + /** Whether the item is only shown when a specific capability is present */ + visibleWhen?: AttachmentItemVisibleWhen; + /** Whether this item has a tooltip even when enabled (uses dynamic text) */ + hasEnabledTooltip?: boolean; +} + +/** + * File attachment menu items shown in both the desktop dropdown and mobile sheet. + * The "Tools" submenu is handled separately by each component. + */ +export const ATTACHMENT_FILE_ITEMS: AttachmentMenuItem[] = [ + { + id: AttachmentMenuItemId.IMAGES, + label: 'Images', + icon: FILE_TYPE_ICONS.image, + class: 'images-button', + enabledWhen: AttachmentItemEnabledWhen.HAS_VISION_MODALITY, + disabledTooltip: 'Image processing requires a vision model', + action: AttachmentAction.FILE_UPLOAD + }, + { + id: AttachmentMenuItemId.AUDIO, + label: 'Audio Files', + icon: FILE_TYPE_ICONS.audio, + class: 'audio-button', + enabledWhen: AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY, + disabledTooltip: 'Audio files processing requires an audio model', + action: AttachmentAction.FILE_UPLOAD + }, + { + id: AttachmentMenuItemId.TEXT, + label: 'Text Files', + icon: FILE_TYPE_ICONS.text, + enabledWhen: AttachmentItemEnabledWhen.ALWAYS, + action: AttachmentAction.FILE_UPLOAD + }, + { + id: AttachmentMenuItemId.PDF, + label: 'PDF Files', + icon: FILE_TYPE_ICONS.pdf, + enabledWhen: AttachmentItemEnabledWhen.ALWAYS, + disabledTooltip: 'PDFs will be converted to text. Image-based PDFs may not work properly.', + hasEnabledTooltip: true, + action: AttachmentAction.FILE_UPLOAD + } +]; + +export const ATTACHMENT_EXTRA_ITEMS: AttachmentMenuItem[] = [ + { + id: AttachmentMenuItemId.SYSTEM_MESSAGE, + label: 'System Message', + icon: MessageSquare, + enabledWhen: AttachmentItemEnabledWhen.ALWAYS, + hasEnabledTooltip: true, + action: AttachmentAction.SYSTEM_PROMPT_CLICK + } +]; + +export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [ + { + id: AttachmentMenuItemId.MCP_PROMPT, + label: 'MCP Prompt', + icon: Zap, + enabledWhen: AttachmentItemEnabledWhen.ALWAYS, + action: AttachmentAction.MCP_PROMPT_CLICK, + visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_PROMPTS_SUPPORT + }, + { + id: AttachmentMenuItemId.MCP_RESOURCES, + label: 'MCP Resources', + icon: FolderOpen, + enabledWhen: AttachmentItemEnabledWhen.ALWAYS, + action: AttachmentAction.MCP_RESOURCES_CLICK, + visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_RESOURCES_SUPPORT + } +]; + +export const ATTACHMENT_TOOLTIP_TEXT = 'Add files, prompts, tools or MCP Servers'; diff --git a/tools/ui/src/lib/constants/auto-scroll.ts b/tools/ui/src/lib/constants/auto-scroll.ts new file mode 100644 index 000000000..ca9ba5a9e --- /dev/null +++ b/tools/ui/src/lib/constants/auto-scroll.ts @@ -0,0 +1,2 @@ +export const AUTO_SCROLL_INTERVAL = 100; +export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10; diff --git a/tools/ui/src/lib/constants/binary-detection.ts b/tools/ui/src/lib/constants/binary-detection.ts new file mode 100644 index 000000000..21a95cc88 --- /dev/null +++ b/tools/ui/src/lib/constants/binary-detection.ts @@ -0,0 +1,7 @@ +import type { BinaryDetectionOptions } from '$lib/types'; + +export const DEFAULT_BINARY_DETECTION_OPTIONS: BinaryDetectionOptions = { + prefixLength: 1024 * 10, // Check the first 10KB of the string + suspiciousCharThresholdRatio: 0.15, // Allow up to 15% suspicious chars + maxAbsoluteNullBytes: 2 +}; diff --git a/tools/ui/src/lib/constants/cache.ts b/tools/ui/src/lib/constants/cache.ts new file mode 100644 index 000000000..07fe86834 --- /dev/null +++ b/tools/ui/src/lib/constants/cache.ts @@ -0,0 +1,54 @@ +/** + * Cache configuration constants + */ + +/** + * Default TTL (Time-To-Live) for cache entries in milliseconds + * @default 5 minutes + */ +export const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * Default maximum number of entries in a cache + * @default 100 + */ +export const DEFAULT_CACHE_MAX_ENTRIES = 100; + +/** + * TTL for model props cache in milliseconds + * Props don't change frequently, so we can cache them longer + * @default 10 minutes + */ +export const MODEL_PROPS_CACHE_TTL_MS = 10 * 60 * 1000; + +/** + * Maximum number of model props to cache + * @default 50 + */ +export const MODEL_PROPS_CACHE_MAX_ENTRIES = 50; + +/** + * Maximum number of MCP resources to cache + * @default 50 + */ +export const MCP_RESOURCE_CACHE_MAX_ENTRIES = 50; + +/** + * TTL for MCP resource cache entries in milliseconds + * @default 5 minutes + */ +export const MCP_RESOURCE_CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * Maximum number of inactive conversation states to keep in memory + * States for conversations beyond this limit will be cleaned up + * @default 10 + */ +export const MAX_INACTIVE_CONVERSATION_STATES = 10; + +/** + * Maximum age (in ms) for inactive conversation states before cleanup + * States older than this will be removed during cleanup + * @default 30 minutes + */ +export const INACTIVE_CONVERSATION_STATE_MAX_AGE_MS = 30 * 60 * 1000; diff --git a/tools/ui/src/lib/constants/chat-form.ts b/tools/ui/src/lib/constants/chat-form.ts new file mode 100644 index 000000000..05ab8c1f8 --- /dev/null +++ b/tools/ui/src/lib/constants/chat-form.ts @@ -0,0 +1,6 @@ +export const INITIAL_FILE_SIZE = 0; +export const PROMPT_CONTENT_SEPARATOR = '\n\n'; +export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"'; +export const PROMPT_TRIGGER_PREFIX = '/'; +export const RESOURCE_TRIGGER_PREFIX = '@'; +export const NEW_CHAT_DRAFT_KEY = '__new_chat__'; diff --git a/tools/ui/src/lib/constants/cli-flags.ts b/tools/ui/src/lib/constants/cli-flags.ts new file mode 100644 index 000000000..4fbee8a36 --- /dev/null +++ b/tools/ui/src/lib/constants/cli-flags.ts @@ -0,0 +1,6 @@ +export const CLI_FLAGS = { + API_KEY: '--api-key', + MCP_PROXY: '--ui-mcp-proxy', + SLOTS: '--slots', + TOOLS: '--tools' +} as const; diff --git a/tools/ui/src/lib/constants/code-blocks.ts b/tools/ui/src/lib/constants/code-blocks.ts new file mode 100644 index 000000000..0f7265104 --- /dev/null +++ b/tools/ui/src/lib/constants/code-blocks.ts @@ -0,0 +1,8 @@ +export const CODE_BLOCK_SCROLL_CONTAINER_CLASS = 'code-block-scroll-container'; +export const CODE_BLOCK_WRAPPER_CLASS = 'code-block-wrapper'; +export const CODE_BLOCK_HEADER_CLASS = 'code-block-header'; +export const CODE_BLOCK_ACTIONS_CLASS = 'code-block-actions'; +export const CODE_LANGUAGE_CLASS = 'code-language'; +export const COPY_CODE_BTN_CLASS = 'copy-code-btn'; +export const PREVIEW_CODE_BTN_CLASS = 'preview-code-btn'; +export const RELATIVE_CLASS = 'relative'; diff --git a/tools/ui/src/lib/constants/code.ts b/tools/ui/src/lib/constants/code.ts new file mode 100644 index 000000000..12bcd0db7 --- /dev/null +++ b/tools/ui/src/lib/constants/code.ts @@ -0,0 +1,7 @@ +export const NEWLINE = '\n'; +export const DEFAULT_LANGUAGE = 'text'; +export const LANG_PATTERN = /^(\w*)\n?/; +export const AMPERSAND_REGEX = /&/g; +export const LT_REGEX = //g; +export const FENCE_PATTERN = /^```|\n```/g; diff --git a/tools/ui/src/lib/constants/context-keys.ts b/tools/ui/src/lib/constants/context-keys.ts new file mode 100644 index 000000000..12de0d0bc --- /dev/null +++ b/tools/ui/src/lib/constants/context-keys.ts @@ -0,0 +1,4 @@ +export const CONTEXT_KEY_MESSAGE_EDIT = 'chat-message-edit'; +export const CONTEXT_KEY_CHAT_ACTIONS = 'chat-actions'; +export const CONTEXT_KEY_CHAT_SETTINGS_CONFIG = 'chat-settings-config'; +export const CONTEXT_KEY_PROCESSING_INFO = 'processing-info'; diff --git a/tools/ui/src/lib/constants/css-classes.ts b/tools/ui/src/lib/constants/css-classes.ts new file mode 100644 index 000000000..ca5386fcd --- /dev/null +++ b/tools/ui/src/lib/constants/css-classes.ts @@ -0,0 +1,19 @@ +export const BOX_BORDER = + 'border border-border/30 focus-within:border-border dark:border-border/20 dark:focus-within:border-border'; + +export const INPUT_CLASSES = ` + bg-muted/60 dark:bg-muted/75 + ${BOX_BORDER} + shadow-sm + outline-none + text-foreground +`; + +export const PANEL_CLASSES = ` + bg-background + border border-border/30 dark:border-border/20 + shadow-sm backdrop-blur-lg! + rounded-t-lg! +`; + +export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80'; diff --git a/tools/ui/src/lib/constants/database.ts b/tools/ui/src/lib/constants/database.ts new file mode 100644 index 000000000..95e698f40 --- /dev/null +++ b/tools/ui/src/lib/constants/database.ts @@ -0,0 +1,29 @@ +/** + * Database-related constants (IndexedDB, Dexie). + * + * Centralized to ensure consistency across the app and simplify future + * naming changes. + */ + +import { STORAGE_APP_NAME } from './storage'; + +/** IndexedDB database name */ +export const DB_NAME = STORAGE_APP_NAME; + +/** IndexedDB store / table names */ +export const IDXDB_TABLES = { + conversations: 'conversations', + messages: 'messages' +} as const; + +/** IndexedDB store schemas */ +export const IDXDB_STORE_SCHEMAS = { + conversations: 'id, lastModified, currNode, name', + messages: 'id, convId, type, role, timestamp, parent, children' +} as const; + +/** Combined Dexie stores definition — keys are table names, values are schemas */ +export const IDXDB_STORES = { + [IDXDB_TABLES.conversations]: IDXDB_STORE_SCHEMAS.conversations, + [IDXDB_TABLES.messages]: IDXDB_STORE_SCHEMAS.messages +} as const; diff --git a/tools/ui/src/lib/constants/floating-ui-constraints.ts b/tools/ui/src/lib/constants/floating-ui-constraints.ts new file mode 100644 index 000000000..003fc77ac --- /dev/null +++ b/tools/ui/src/lib/constants/floating-ui-constraints.ts @@ -0,0 +1,2 @@ +export const VIEWPORT_GUTTER = 8; +export const MENU_OFFSET = 6; diff --git a/tools/ui/src/lib/constants/formatters.ts b/tools/ui/src/lib/constants/formatters.ts new file mode 100644 index 000000000..d6d1b883f --- /dev/null +++ b/tools/ui/src/lib/constants/formatters.ts @@ -0,0 +1,8 @@ +export const MS_PER_SECOND = 1000; +export const SECONDS_PER_MINUTE = 60; +export const SECONDS_PER_HOUR = 3600; +export const SHORT_DURATION_THRESHOLD = 1; +export const MEDIUM_DURATION_THRESHOLD = 10; + +/** Default display value when no performance time is available */ +export const DEFAULT_PERFORMANCE_TIME = '0s'; diff --git a/tools/ui/src/lib/constants/icons.ts b/tools/ui/src/lib/constants/icons.ts new file mode 100644 index 000000000..1e88ab5b3 --- /dev/null +++ b/tools/ui/src/lib/constants/icons.ts @@ -0,0 +1,32 @@ +/** + * Icon mappings for file types and model modalities + * Centralized configuration to ensure consistent icon usage across the app + */ + +import { + File as FileIcon, + FileText as FileTextIcon, + Image as ImageIcon, + Eye as VisionIcon, + Mic as AudioIcon +} from '@lucide/svelte'; +import { FileTypeCategory, ModelModality } from '$lib/enums'; + +export const FILE_TYPE_ICONS = { + [FileTypeCategory.IMAGE]: ImageIcon, + [FileTypeCategory.AUDIO]: AudioIcon, + [FileTypeCategory.TEXT]: FileTextIcon, + [FileTypeCategory.PDF]: FileIcon +} as const; + +export const DEFAULT_FILE_ICON = FileIcon; + +export const MODALITY_ICONS = { + [ModelModality.VISION]: VisionIcon, + [ModelModality.AUDIO]: AudioIcon +} as const; + +export const MODALITY_LABELS = { + [ModelModality.VISION]: 'Vision', + [ModelModality.AUDIO]: 'Audio' +} as const; diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts new file mode 100644 index 000000000..d3a434802 --- /dev/null +++ b/tools/ui/src/lib/constants/index.ts @@ -0,0 +1,45 @@ +// Central constants export file +// All constants should be imported from '$lib/constants' + +export * from './agentic'; +export * from './api-endpoints'; +export * from './attachment-labels'; +export * from './database'; +export * from './storage'; +export * from './attachment-menu'; +export * from './auto-scroll'; +export * from './binary-detection'; +export * from './cache'; +export * from './chat-form'; +export * from './cli-flags'; +export * from './code-blocks'; +export * from './code'; +export * from './context-keys'; +export * from './css-classes'; +export * from './floating-ui-constraints'; +export * from './formatters'; +export * from './key-value-pairs'; +export * from './icons'; +export * from './latex-protection'; +export * from './literal-html'; +export * from './markdown'; +export * from './max-bundle-size'; +export * from './mcp'; +export * from './mcp-form'; +export * from './mcp-resource'; +export * from './message-export'; +export * from './model-id'; +export * from './precision'; +export * from './processing-info'; +export * from './routes'; +export * from './settings-keys'; +export * from './settings-registry'; +export * from './supported-file-types'; +export * from './table-html-restorer'; +export * from './title-generation'; +export * from './tools'; +export * from './tooltip-config'; +export * from './ui'; +export * from './uri-template'; +export * from './url'; +export * from './viewport'; diff --git a/tools/ui/src/lib/constants/key-value-pairs.ts b/tools/ui/src/lib/constants/key-value-pairs.ts new file mode 100644 index 000000000..48dadbec4 --- /dev/null +++ b/tools/ui/src/lib/constants/key-value-pairs.ts @@ -0,0 +1,20 @@ +/** + * Key-value pair form constraints and sanitization patterns. + * + * Both regexes target characters dangerous in HTTP-header / env-var contexts: + * \x00 – null byte (injection) + * \x0A (\n) – LF (HTTP header injection / response splitting) + * \x0D (\r) – CR (HTTP header injection / response splitting) + * \x01–\x08, \x0B–\x0C, \x0E–\x1F, \x7F – other C0/DEL control chars + * + * KEY_UNSAFE_RE additionally strips TAB (\x09); values keep TAB because it is + * a valid header-value continuation character per RFC 7230. + */ + +export const KEY_VALUE_PAIR_KEY_MAX_LENGTH = 256; +export const KEY_VALUE_PAIR_VALUE_MAX_LENGTH = 8192; + +// eslint-disable-next-line no-control-regex +export const KEY_VALUE_PAIR_UNSAFE_KEY_RE = /[\x00-\x1F\x7F]/g; +// eslint-disable-next-line no-control-regex +export const KEY_VALUE_PAIR_UNSAFE_VALUE_RE = /[\x00-\x08\x0A-\x0D\x0E-\x1F\x7F]/g; diff --git a/tools/ui/src/lib/constants/latex-protection.ts b/tools/ui/src/lib/constants/latex-protection.ts new file mode 100644 index 000000000..27c88e725 --- /dev/null +++ b/tools/ui/src/lib/constants/latex-protection.ts @@ -0,0 +1,35 @@ +/** + * Matches common Markdown code blocks to exclude them from further processing (e.g. LaTeX). + * - Fenced: ```...``` + * - Inline: `...` (does NOT support nested backticks or multi-backtick syntax) + * + * Note: This pattern does not handle advanced cases like: + * `` `code with `backticks` `` or \\``...\\`` + */ +export const CODE_BLOCK_REGEXP = /(```[\s\S]*?```|`[^`\n]+`)/g; + +/** + * Matches LaTeX math delimiters \(...\) and \[...\] only when not preceded by a backslash (i.e., not escaped), + * while also capturing code blocks (```, `...`) so they can be skipped during processing. + * + * Uses negative lookbehind `(? = { + [MimeTypeImage.JPEG]: 'jpg', + [MimeTypeImage.JPG]: 'jpg', + [MimeTypeImage.PNG]: 'png', + [MimeTypeImage.GIF]: 'gif', + [MimeTypeImage.WEBP]: 'webp' +} as const; diff --git a/tools/ui/src/lib/constants/mcp.ts b/tools/ui/src/lib/constants/mcp.ts new file mode 100644 index 000000000..19bdd92ea --- /dev/null +++ b/tools/ui/src/lib/constants/mcp.ts @@ -0,0 +1,86 @@ +import { Zap, Globe, Radio } from '@lucide/svelte'; +import { MCPTransportType } from '$lib/enums'; +import type { ClientCapabilities, Implementation } from '$lib/types'; +import type { Component } from 'svelte'; +import { MimeTypeImage } from '$lib/enums/files'; + +export const DEFAULT_CLIENT_VERSION = '1.0.0'; +export const MCP_CLIENT_NAME = 'llama-ui-mcp'; +export const DEFAULT_IMAGE_MIME_TYPE = MimeTypeImage.PNG; + +/** MIME types considered safe for rendering MCP server icons */ +export const MCP_ALLOWED_ICON_MIME_TYPES = new Set([ + MimeTypeImage.PNG, + MimeTypeImage.JPEG, + MimeTypeImage.JPG, + MimeTypeImage.SVG, + MimeTypeImage.WEBP, + MimeTypeImage.ICO, + MimeTypeImage.ICO_MICROSOFT +]); + +/** + * MCP specification version this client targets. + * Update when the upstream MCP spec introduces a new stable version: + * https://spec.modelcontextprotocol.io/ + */ +export const MCP_PROTOCOL_VERSION = '2025-06-18'; + +export const DEFAULT_MCP_CONFIG = { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: true } } as ClientCapabilities, + clientInfo: { name: MCP_CLIENT_NAME, version: DEFAULT_CLIENT_VERSION } as Implementation, + requestTimeoutSeconds: 300, // 5 minutes for long-running tools + connectionTimeoutMs: 10_000 // 10 seconds for connection establishment +} as const; + +export const MCP_SERVER_ID_PREFIX = 'LlamaUI-MCP-Server'; + +export const MCP_RECONNECT_INITIAL_DELAY = 1000; +export const MCP_RECONNECT_BACKOFF_MULTIPLIER = 2; +export const MCP_RECONNECT_MAX_DELAY = 30000; +/** Per-attempt timeout for a single reconnection attempt before giving up and backing off. */ +export const MCP_RECONNECT_ATTEMPT_TIMEOUT_MS = 15_000; + +/** Maximum number of MCP server avatars to display in the chat form */ +export const MAX_DISPLAYED_MCP_AVATARS = 4; + +/** Expected count when two theme-less icons represent a light/dark pair */ +export const EXPECTED_THEMED_ICON_PAIR_COUNT = 2; + +/** CORS proxy URL query parameter name */ +export const CORS_PROXY_URL_PARAM = 'url'; + +/** Number of trailing characters to keep visible when partially redacting mcp-session-id */ +export const MCP_SESSION_ID_VISIBLE_CHARS = 5; + +/** Partial-redaction rules for MCP headers: header name -> visible trailing chars */ +export const MCP_PARTIAL_REDACT_HEADERS = new Map([ + ['mcp-session-id', MCP_SESSION_ID_VISIBLE_CHARS] +]); + +/** Header names whose values should be redacted in diagnostic logs */ +export const REDACTED_HEADERS = new Set([ + 'authorization', + 'api-key', + 'cookie', + 'mcp-session-id', + 'proxy-authorization', + 'set-cookie', + 'x-auth-token', + 'x-api-key' +]); + +/** Human-readable labels for MCP transport types */ +export const MCP_TRANSPORT_LABELS: Record = { + [MCPTransportType.WEBSOCKET]: 'WebSocket', + [MCPTransportType.STREAMABLE_HTTP]: 'HTTP', + [MCPTransportType.SSE]: 'SSE' +}; + +/** Icon components for MCP transport types */ +export const MCP_TRANSPORT_ICONS: Record = { + [MCPTransportType.WEBSOCKET]: Zap, + [MCPTransportType.STREAMABLE_HTTP]: Globe, + [MCPTransportType.SSE]: Radio +}; diff --git a/tools/ui/src/lib/constants/message-export.ts b/tools/ui/src/lib/constants/message-export.ts new file mode 100644 index 000000000..79fa36f91 --- /dev/null +++ b/tools/ui/src/lib/constants/message-export.ts @@ -0,0 +1,20 @@ +// Conversation filename constants + +// Length of the trimmed conversation ID in the filename +export const EXPORT_CONV_ID_TRIM_LENGTH = 8; +// Maximum length of the sanitized conversation name snippet +export const EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH = 20; +// Characters to keep in the ISO timestamp. 19 keeps 2026-01-01T00:00:00 +export const ISO_TIMESTAMP_SLICE_LENGTH = 19; + +// Replacements for making the conversation title filename-friendly +export const NON_ALPHANUMERIC_REGEX = /[^a-z0-9]/gi; +export const EXPORT_CONV_NONALNUM_REPLACEMENT = '_'; +export const MULTIPLE_UNDERSCORE_REGEX = /_+/g; + +// Replacements to the ISO date for use in the export filename +export const ISO_DATE_TIME_SEPARATOR = 'T'; +export const ISO_DATE_TIME_SEPARATOR_REPLACEMENT = '_'; + +export const ISO_TIME_SEPARATOR = ':'; +export const ISO_TIME_SEPARATOR_REPLACEMENT = '-'; diff --git a/tools/ui/src/lib/constants/model-id.ts b/tools/ui/src/lib/constants/model-id.ts new file mode 100644 index 000000000..ee314d167 --- /dev/null +++ b/tools/ui/src/lib/constants/model-id.ts @@ -0,0 +1,39 @@ +/** Sentinel value returned by `indexOf` when a substring is not found. */ +export const MODEL_ID_NOT_FOUND = -1; + +/** Separates `` from `` in a model ID, e.g. `org/ModelName`. */ +export const MODEL_ID_ORG_SEPARATOR = '/'; + +/** Separates named segments within the model path, e.g. `ModelName-7B-GGUF`. */ +export const MODEL_ID_SEGMENT_SEPARATOR = '-'; + +/** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */ +export const MODEL_ID_QUANTIZATION_SEPARATOR = ':'; + +/** + * Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `F16`, `BF16`, `MXFP4`. + * Case-insensitive to handle both uppercase and lowercase inputs. + */ +export const MODEL_QUANTIZATION_SEGMENT_RE = + /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i; + +/** + * Matches prefix for custom quantization types, e.g. `UD-Q8_K_XL`. + */ +export const MODEL_CUSTOM_QUANTIZATION_PREFIX_RE = /^UD$/i; + +/** + * Matches a parameter-count segment, e.g. `7B`, `1.5b`, `120M`. + */ +export const MODEL_PARAMS_RE = /^\d+(\.\d+)?[BbMmKkTt]$/; + +/** + * Matches an activated-parameter-count segment, e.g. `A10B`, `a2.4b`. + * The leading `A`/`a` distinguishes it from a regular params segment. + */ +export const MODEL_ACTIVATED_PARAMS_RE = /^[Aa]\d+(\.\d+)?[BbMmKkTt]$/; + +/** + * Container format segments to exclude from tags (every model uses these). + */ +export const MODEL_IGNORED_SEGMENTS = new Set(['GGUF', 'GGML']); diff --git a/tools/ui/src/lib/constants/precision.ts b/tools/ui/src/lib/constants/precision.ts new file mode 100644 index 000000000..8df5c4f96 --- /dev/null +++ b/tools/ui/src/lib/constants/precision.ts @@ -0,0 +1,2 @@ +export const PRECISION_MULTIPLIER = 1000000; +export const PRECISION_DECIMAL_PLACES = 6; diff --git a/tools/ui/src/lib/constants/processing-info.ts b/tools/ui/src/lib/constants/processing-info.ts new file mode 100644 index 000000000..2c3f7dc53 --- /dev/null +++ b/tools/ui/src/lib/constants/processing-info.ts @@ -0,0 +1,8 @@ +export const PROCESSING_INFO_TIMEOUT = 2000; + +/** + * Statistics units labels + */ +export const STATS_UNITS = { + TOKENS_PER_SECOND: 't/s' +} as const; diff --git a/tools/ui/src/lib/constants/routes.ts b/tools/ui/src/lib/constants/routes.ts new file mode 100644 index 000000000..14416478f --- /dev/null +++ b/tools/ui/src/lib/constants/routes.ts @@ -0,0 +1,26 @@ +export const NEW_CHAT_PARAM = 'new_chat'; + +/** Settings section slugs — used for routes and navigation. */ +export const SETTINGS_SECTION_SLUGS = { + GENERAL: 'general', + DISPLAY: 'display', + SAMPLING: 'sampling', + PENALTIES: 'penalties', + AGENTIC: 'agentic', + DEVELOPER: 'developer', + TOOLS: 'tools', + IMPORT_EXPORT: 'import-export' +} as const; + +export const ROUTES = { + /** Root — start of the app. */ + START: '#/', + /** New chat — root with new chat query param. */ + NEW_CHAT: `?${NEW_CHAT_PARAM}=true#/`, + /** Chat base — for dynamic chat URLs use RouterService. */ + CHAT: '#/chat', + /** MCP servers. */ + MCP_SERVERS: '#/mcp-servers', + /** Settings base — for dynamic settings URLs use RouterService. */ + SETTINGS: '#/settings' +} as const; diff --git a/tools/ui/src/lib/constants/settings-keys.ts b/tools/ui/src/lib/constants/settings-keys.ts new file mode 100644 index 000000000..b673bff27 --- /dev/null +++ b/tools/ui/src/lib/constants/settings-keys.ts @@ -0,0 +1,68 @@ +/** + * Settings key constants for ChatSettings configuration. + * + * These keys correspond to properties in SettingsConfigType and are used + * in settings field configurations to ensure consistency. + */ +export const SETTINGS_KEYS = { + // General + THEME: 'theme', + API_KEY: 'apiKey', + SYSTEM_MESSAGE: 'systemMessage', + PASTE_LONG_TEXT_TO_FILE_LEN: 'pasteLongTextToFileLen', + COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT: 'copyTextAttachmentsAsPlainText', + SEND_ON_ENTER: 'sendOnEnter', + ENABLE_CONTINUE_GENERATION: 'enableContinueGeneration', + PDF_AS_IMAGE: 'pdfAsImage', + ASK_FOR_TITLE_CONFIRMATION: 'askForTitleConfirmation', + TITLE_GENERATION_USE_FIRST_LINE: 'titleGenerationUseFirstLine', + TITLE_GENERATION_USE_LLM: 'titleGenerationUseLLM', + TITLE_GENERATION_PROMPT: 'titleGenerationPrompt', + // Display + SHOW_MESSAGE_STATS: 'showMessageStats', + SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress', + KEEP_STATS_VISIBLE: 'keepStatsVisible', + AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty', + RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown', + DISABLE_AUTO_SCROLL: 'disableAutoScroll', + ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop', + FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks', + SHOW_RAW_MODEL_NAMES: 'showRawModelNames', + SHOW_SYSTEM_MESSAGE: 'showSystemMessage', + // Sampling + TEMPERATURE: 'temperature', + DYNATEMP_RANGE: 'dynatemp_range', + DYNATEMP_EXPONENT: 'dynatemp_exponent', + TOP_K: 'top_k', + TOP_P: 'top_p', + MIN_P: 'min_p', + XTC_PROBABILITY: 'xtc_probability', + XTC_THRESHOLD: 'xtc_threshold', + TYP_P: 'typ_p', + MAX_TOKENS: 'max_tokens', + SAMPLERS: 'samplers', + BACKEND_SAMPLING: 'backend_sampling', + // Penalties + REPEAT_LAST_N: 'repeat_last_n', + REPEAT_PENALTY: 'repeat_penalty', + PRESENCE_PENALTY: 'presence_penalty', + FREQUENCY_PENALTY: 'frequency_penalty', + DRY_MULTIPLIER: 'dry_multiplier', + DRY_BASE: 'dry_base', + DRY_ALLOWED_LENGTH: 'dry_allowed_length', + DRY_PENALTY_LAST_N: 'dry_penalty_last_n', + // MCP + MCP_SERVERS: 'mcpServers', + AGENTIC_MAX_TURNS: 'agenticMaxTurns', + ALWAYS_SHOW_AGENTIC_TURNS: 'alwaysShowAgenticTurns', + AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines', + SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress', + // Performance + PRE_ENCODE_CONVERSATION: 'preEncodeConversation', + // Developer + DISABLE_REASONING_PARSING: 'disableReasoningParsing', + EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext', + SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch', + // PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled', + CUSTOM: 'custom' +} as const; diff --git a/tools/ui/src/lib/constants/settings-registry.ts b/tools/ui/src/lib/constants/settings-registry.ts new file mode 100644 index 000000000..c4fc3fb30 --- /dev/null +++ b/tools/ui/src/lib/constants/settings-registry.ts @@ -0,0 +1,759 @@ +import { ColorMode } from '$lib/enums/ui'; +import { SettingsFieldType } from '$lib/enums/settings'; +import { SyncableParameterType } from '$lib/enums'; +import { + Funnel, + AlertTriangle, + Code, + Monitor, + ListRestart, + Sliders, + PencilRuler, + Database, + Monitor as MonitorIcon, + Sun, + Moon +} from '@lucide/svelte'; +import type { Component } from 'svelte'; +import type { + SettingsConfigValue, + SyncableParameter, + SettingsEntry, + SettingsSectionTitle, + SettingsSectionEntry, + SettingsSection +} from '$lib/types'; +import { CLI_FLAGS } from '$lib/constants'; +import { SETTINGS_KEYS } from './settings-keys'; +import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes'; +import { TITLE_GENERATION } from './title-generation'; + +export const SETTINGS_SECTION_TITLES = { + GENERAL: 'General', + DISPLAY: 'Display', + SAMPLING: 'Sampling', + PENALTIES: 'Penalties', + AGENTIC: 'Agentic', + TOOLS: 'Tools', + IMPORT_EXPORT: 'Import/Export', + DEVELOPER: 'Developer' +} as const; + +const STANDALONE_SECTIONS: { title: SettingsSectionTitle; slug: string; icon: Component }[] = [ + { title: SETTINGS_SECTION_TITLES.TOOLS, slug: SETTINGS_SECTION_SLUGS.TOOLS, icon: PencilRuler }, + { + title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT, + slug: SETTINGS_SECTION_SLUGS.IMPORT_EXPORT, + icon: Database + } +]; + +const COLOR_MODE_OPTIONS: Array<{ value: string; label: string; icon: Component }> = [ + { value: ColorMode.SYSTEM, label: 'System', icon: MonitorIcon }, + { value: ColorMode.LIGHT, label: 'Light', icon: Sun }, + { value: ColorMode.DARK, label: 'Dark', icon: Moon } +]; + +const SETTINGS_REGISTRY: Record = { + [SETTINGS_SECTION_SLUGS.GENERAL]: { + title: SETTINGS_SECTION_TITLES.GENERAL, + slug: SETTINGS_SECTION_SLUGS.GENERAL, + icon: Sliders, + settings: [ + { + key: SETTINGS_KEYS.THEME, + label: 'Theme', + help: 'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.', + defaultValue: ColorMode.SYSTEM, + type: SettingsFieldType.SELECT, + section: SETTINGS_SECTION_SLUGS.GENERAL, + options: COLOR_MODE_OPTIONS, + sync: { serverKey: SETTINGS_KEYS.THEME, paramType: SyncableParameterType.STRING } + }, + { + key: SETTINGS_KEYS.API_KEY, + label: 'API Key', + help: `Set the API Key if you are using ${CLI_FLAGS.API_KEY} option for the server.`, + defaultValue: '', + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.GENERAL + }, + { + key: SETTINGS_KEYS.SYSTEM_MESSAGE, + label: 'System Message', + help: 'The starting message that defines how model should behave.', + defaultValue: '', + type: SettingsFieldType.TEXTAREA, + section: SETTINGS_SECTION_SLUGS.GENERAL, + sync: { + serverKey: SETTINGS_KEYS.SYSTEM_MESSAGE, + paramType: SyncableParameterType.STRING + } + }, + { + key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN, + label: 'Paste long text to file length', + help: 'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.', + defaultValue: 2500, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.GENERAL, + sync: { + serverKey: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.SEND_ON_ENTER, + label: 'Send message on Enter', + help: 'Use Enter to send messages and Shift + Enter for new lines. When disabled, use Ctrl/Cmd + Enter.', + defaultValue: true, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.GENERAL, + sync: { + serverKey: SETTINGS_KEYS.SEND_ON_ENTER, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT, + label: 'Copy text attachments as plain text', + help: 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.GENERAL, + sync: { + serverKey: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION, + label: 'Enable "Continue" button', + help: 'Enable "Continue" button for assistant messages, including reasoning models.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.GENERAL, + isExperimental: true, + sync: { + serverKey: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.PDF_AS_IMAGE, + label: 'Parse PDF as image', + help: 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.GENERAL, + sync: { + serverKey: SETTINGS_KEYS.PDF_AS_IMAGE, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION, + label: 'Ask for confirmation before changing conversation title', + help: 'Ask for confirmation before automatically changing conversation title when editing the first message.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.GENERAL, + sync: { + serverKey: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE, + label: 'Use first non-empty line for conversation title', + help: 'Use only the first non-empty line of the prompt to generate the conversation title.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.GENERAL, + sync: { + serverKey: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM, + label: 'Use LLM to generate conversation title', + help: 'Use the LLM to automatically generate conversation titles based on the first message exchange.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.GENERAL, + isExperimental: true + }, + { + key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT, + label: 'LLM title generation prompt', + help: 'Optional template for the title generation prompt. Use {{USER}} for the user message and {{ASSISTANT}} for the assistant message.', + defaultValue: TITLE_GENERATION.DEFAULT_PROMPT, + type: SettingsFieldType.TEXTAREA, + section: SETTINGS_SECTION_SLUGS.GENERAL + } + ] + }, + [SETTINGS_SECTION_SLUGS.DISPLAY]: { + title: SETTINGS_SECTION_TITLES.DISPLAY, + slug: SETTINGS_SECTION_SLUGS.DISPLAY, + icon: Monitor, + settings: [ + { + key: SETTINGS_KEYS.SHOW_MESSAGE_STATS, + label: 'Show message generation statistics', + help: 'Display generation statistics (tokens/second, token count, duration) below each assistant message.', + defaultValue: true, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DISPLAY, + sync: { + serverKey: SETTINGS_KEYS.SHOW_MESSAGE_STATS, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS, + label: 'Show thought in progress', + help: 'Expand thought process by default when generating messages.', + defaultValue: true, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DISPLAY, + sync: { + serverKey: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS, + label: 'Show tool call in progress', + help: 'Automatically expand tool call details while executing and keep them expanded after completion.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DISPLAY, + sync: { + serverKey: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.KEEP_STATS_VISIBLE, + label: 'Keep stats visible after generation', + help: 'Keep processing statistics visible after generation finishes.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DISPLAY, + sync: { + serverKey: SETTINGS_KEYS.KEEP_STATS_VISIBLE, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY, + label: 'Show microphone on empty input', + help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DISPLAY, + isExperimental: true, + sync: { + serverKey: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN, + label: 'Render user content as Markdown', + help: 'Render user messages using markdown formatting in the chat.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DISPLAY, + sync: { + serverKey: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS, + label: 'Use full height code blocks', + help: 'Always display code blocks at their full natural height, overriding any height limits.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DISPLAY, + sync: { + serverKey: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL, + label: 'Disable automatic scroll', + help: 'Disable automatic scrolling while messages stream so you can control the viewport position manually.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DISPLAY, + sync: { + serverKey: SETTINGS_KEYS.DISABLE_AUTO_SCROLL, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP, + label: 'Always show sidebar on desktop', + help: 'Always keep the sidebar visible on desktop instead of auto-hiding it.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DISPLAY, + sync: { + serverKey: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES, + label: 'Show raw model names', + help: 'Display full raw model identifiers (e.g. "ggml-org/GLM-4.7-Flash-GGUF:Q8_0") instead of parsed names with badges.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DISPLAY, + sync: { + serverKey: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS, + label: 'Always show agentic turns in conversation', + help: 'Always expand and display agentic loop turns in conversation messages.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DISPLAY, + sync: { + serverKey: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS, + paramType: SyncableParameterType.BOOLEAN + } + } + ] + }, + [SETTINGS_SECTION_SLUGS.SAMPLING]: { + title: SETTINGS_SECTION_TITLES.SAMPLING, + slug: SETTINGS_SECTION_SLUGS.SAMPLING, + icon: Funnel, + settings: [ + { + key: SETTINGS_KEYS.TEMPERATURE, + label: 'Temperature', + help: 'Controls the randomness of the generated text by affecting the probability distribution of the output tokens. Higher = more random, lower = more focused.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.SAMPLING, + sync: { + serverKey: SETTINGS_KEYS.TEMPERATURE, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.DYNATEMP_RANGE, + label: 'Dynamic temperature range', + help: 'Addon for the temperature sampler. The added value to the range of dynamic temperature, which adjusts probabilities by entropy of tokens.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.SAMPLING, + sync: { + serverKey: SETTINGS_KEYS.DYNATEMP_RANGE, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.DYNATEMP_EXPONENT, + label: 'Dynamic temperature exponent', + help: 'Addon for the temperature sampler. Smoothes out the probability redistribution based on the most probable token.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.SAMPLING, + sync: { + serverKey: SETTINGS_KEYS.DYNATEMP_EXPONENT, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.TOP_K, + label: 'Top K', + help: 'Keeps only k top tokens.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.SAMPLING, + sync: { serverKey: SETTINGS_KEYS.TOP_K, paramType: SyncableParameterType.NUMBER } + }, + { + key: SETTINGS_KEYS.TOP_P, + label: 'Top P', + help: 'Limits tokens to those that together have a cumulative probability of at least p', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.SAMPLING, + sync: { serverKey: SETTINGS_KEYS.TOP_P, paramType: SyncableParameterType.NUMBER } + }, + { + key: SETTINGS_KEYS.MIN_P, + label: 'Min P', + help: 'Limits tokens based on the minimum probability for a token to be considered, relative to the probability of the most likely token.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.SAMPLING, + sync: { serverKey: SETTINGS_KEYS.MIN_P, paramType: SyncableParameterType.NUMBER } + }, + { + key: SETTINGS_KEYS.XTC_PROBABILITY, + label: 'XTC probability', + help: 'XTC sampler cuts out top tokens; this parameter controls the chance of cutting tokens at all. 0 disables XTC.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.SAMPLING, + sync: { + serverKey: SETTINGS_KEYS.XTC_PROBABILITY, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.XTC_THRESHOLD, + label: 'XTC threshold', + help: 'XTC sampler cuts out top tokens; this parameter controls the token probability that is required to cut that token.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.SAMPLING, + sync: { + serverKey: SETTINGS_KEYS.XTC_THRESHOLD, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.TYP_P, + label: 'Typical P', + help: 'Sorts and limits tokens based on the difference between log-probability and entropy.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.SAMPLING, + sync: { serverKey: SETTINGS_KEYS.TYP_P, paramType: SyncableParameterType.NUMBER } + }, + { + key: SETTINGS_KEYS.MAX_TOKENS, + label: 'Max tokens', + help: 'The maximum number of token per output. Use -1 for infinite (no limit).', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.SAMPLING, + sync: { + serverKey: SETTINGS_KEYS.MAX_TOKENS, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.SAMPLERS, + label: 'Samplers', + help: 'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature', + defaultValue: '', + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.SAMPLING, + sync: { serverKey: SETTINGS_KEYS.SAMPLERS, paramType: SyncableParameterType.STRING } + }, + { + key: SETTINGS_KEYS.BACKEND_SAMPLING, + label: 'Backend sampling', + help: 'Enable backend-based samplers. When enabled, supported samplers run on the accelerator backend for faster sampling.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.SAMPLING, + sync: { + serverKey: SETTINGS_KEYS.BACKEND_SAMPLING, + paramType: SyncableParameterType.BOOLEAN + } + } + ] + }, + [SETTINGS_SECTION_SLUGS.PENALTIES]: { + title: SETTINGS_SECTION_TITLES.PENALTIES, + slug: SETTINGS_SECTION_SLUGS.PENALTIES, + icon: AlertTriangle, + settings: [ + { + key: SETTINGS_KEYS.REPEAT_LAST_N, + label: 'Repeat last N', + help: 'Last n tokens to consider for penalizing repetition', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.PENALTIES, + sync: { + serverKey: SETTINGS_KEYS.REPEAT_LAST_N, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.REPEAT_PENALTY, + label: 'Repeat penalty', + help: 'Controls the repetition of token sequences in the generated text', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.PENALTIES, + sync: { + serverKey: SETTINGS_KEYS.REPEAT_PENALTY, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.PRESENCE_PENALTY, + label: 'Presence penalty', + help: 'Limits tokens based on whether they appear in the output or not.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.PENALTIES, + sync: { + serverKey: SETTINGS_KEYS.PRESENCE_PENALTY, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.FREQUENCY_PENALTY, + label: 'Frequency penalty', + help: 'Limits tokens based on how often they appear in the output.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.PENALTIES, + sync: { + serverKey: SETTINGS_KEYS.FREQUENCY_PENALTY, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.DRY_MULTIPLIER, + label: 'DRY multiplier', + help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.PENALTIES, + sync: { + serverKey: SETTINGS_KEYS.DRY_MULTIPLIER, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.DRY_BASE, + label: 'DRY base', + help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.PENALTIES, + sync: { serverKey: SETTINGS_KEYS.DRY_BASE, paramType: SyncableParameterType.NUMBER } + }, + { + key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH, + label: 'DRY allowed length', + help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.PENALTIES, + sync: { + serverKey: SETTINGS_KEYS.DRY_ALLOWED_LENGTH, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.DRY_PENALTY_LAST_N, + label: 'DRY penalty last N', + help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.', + defaultValue: undefined, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.PENALTIES, + sync: { + serverKey: SETTINGS_KEYS.DRY_PENALTY_LAST_N, + paramType: SyncableParameterType.NUMBER + } + } + ] + }, + [SETTINGS_SECTION_SLUGS.AGENTIC]: { + title: SETTINGS_SECTION_TITLES.AGENTIC, + slug: SETTINGS_SECTION_SLUGS.AGENTIC, + icon: ListRestart, + settings: [ + { + key: SETTINGS_KEYS.AGENTIC_MAX_TURNS, + label: 'Agentic turns', + help: 'Maximum number of tool execution cycles before stopping (prevents infinite loops).', + defaultValue: 10, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.AGENTIC, + isPositiveInteger: true, + sync: { + serverKey: SETTINGS_KEYS.AGENTIC_MAX_TURNS, + paramType: SyncableParameterType.NUMBER + } + }, + { + key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES, + label: 'Max lines per tool preview', + help: 'Number of lines shown in tool output previews (last N lines). Only these previews and the final LLM response persist after the agentic loop completes.', + defaultValue: 25, + type: SettingsFieldType.INPUT, + section: SETTINGS_SECTION_SLUGS.AGENTIC, + isPositiveInteger: true, + sync: { + serverKey: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES, + paramType: SyncableParameterType.NUMBER + } + } + ] + }, + [SETTINGS_SECTION_SLUGS.DEVELOPER]: { + title: SETTINGS_SECTION_TITLES.DEVELOPER, + slug: SETTINGS_SECTION_SLUGS.DEVELOPER, + icon: Code, + settings: [ + { + key: SETTINGS_KEYS.PRE_ENCODE_CONVERSATION, + label: 'Pre-fill KV cache after response', + help: 'After each response, re-submit the conversation to pre-fill the server KV cache. Makes the next turn faster since the prompt is already encoded while you read the response.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DEVELOPER + }, + { + key: SETTINGS_KEYS.DISABLE_REASONING_PARSING, + label: 'Disable reasoning content parsing', + help: 'Send reasoning_format=none so the server returns thinking tokens inline instead of extracting them into a separate field.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DEVELOPER + }, + { + key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT, + label: 'Exclude reasoning from context', + help: 'Strip thinking from previous messages before sending. When off, thinking is sent back via the reasoning_content field so the model sees its own chain-of-thought across turns.', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DEVELOPER, + sync: { + serverKey: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH, + label: 'Enable raw output toggle', + help: 'Show toggle button to display messages as plain text instead of Markdown-formatted content', + defaultValue: false, + type: SettingsFieldType.CHECKBOX, + section: SETTINGS_SECTION_SLUGS.DEVELOPER, + sync: { + serverKey: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.CUSTOM, + label: 'Custom JSON', + help: 'Custom JSON parameters to send to the API. Must be valid JSON format.', + defaultValue: '', + type: SettingsFieldType.TEXTAREA, + section: SETTINGS_SECTION_SLUGS.DEVELOPER + } + ] + } +} as const; + +const NON_UI_SETTINGS: SettingsEntry[] = [ + { + key: SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, + label: 'Show system message', + help: 'Display the system message at the top of each conversation.', + defaultValue: true, + type: SettingsFieldType.CHECKBOX, + sync: { + serverKey: SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, + paramType: SyncableParameterType.BOOLEAN + } + }, + { + key: SETTINGS_KEYS.MCP_SERVERS, + label: 'MCP servers', + help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.', + defaultValue: '[]', + type: SettingsFieldType.INPUT, + sync: { serverKey: SETTINGS_KEYS.MCP_SERVERS, paramType: SyncableParameterType.STRING } + } + // { + // key: SETTINGS_KEYS.PY_INTERPRETER_ENABLED, + // label: 'Python interpreter enabled', + // help: 'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.', + // defaultValue: false, + // type: SettingsFieldType.CHECKBOX, + // isExperimental: true, + // sync: { serverKey: SETTINGS_KEYS.PY_INTERPRETER_ENABLED, paramType: SyncableParameterType.BOOLEAN } + // } +]; + +function getAllSettings(): SettingsEntry[] { + const result: SettingsEntry[] = []; + for (const section of Object.values(SETTINGS_REGISTRY)) { + result.push(...section.settings); + } + result.push(...NON_UI_SETTINGS); + return result; +} + +/** Flat config object stored in localStorage. */ +export const SETTING_CONFIG_DEFAULT: Record = Object.fromEntries( + getAllSettings().map((s) => [s.key, s.defaultValue]) +) as Record; + +/** Help text for every setting (including non-UI). */ +export const SETTING_CONFIG_INFO: Record = Object.fromEntries( + getAllSettings().map((s) => [s.key, s.help]) +) as Record; + +/** Theme select options. */ +export const SETTINGS_COLOR_MODES_CONFIG = COLOR_MODE_OPTIONS; + +export type { SettingsSectionTitle } from '$lib/types'; +export type { SettingsSection } from '$lib/types'; + +/** Sidebar sections + field configs (as consumed by UI). */ +export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [ + ...Object.values(SETTINGS_REGISTRY).map((section) => ({ + title: section.title, + slug: section.slug, + icon: section.icon, + fields: section.settings.map((s) => ({ + key: s.key, + label: s.label, + type: s.type, + isExperimental: s.isExperimental, + help: s.help, + options: s.options + })) + })), + ...STANDALONE_SECTIONS +]; + +/** INPUT-type settings whose value is a number. */ +export const NUMERIC_FIELDS = getAllSettings() + .filter((s) => s.type === SettingsFieldType.INPUT && typeof s.defaultValue !== 'string') + .map((s) => s.key) as readonly string[]; + +/** Numeric fields clamped to ≥ 1 and rounded. */ +export const POSITIVE_INTEGER_FIELDS = getAllSettings() + .filter((s) => s.isPositiveInteger) + .map((s) => s.key) as readonly string[]; + +/** Derived for the parameter sync service. */ +export const SYNCABLE_PARAMETERS: SyncableParameter[] = getAllSettings() + .filter((s) => s.sync !== undefined) + .map((s) => ({ + key: s.key, + serverKey: s.sync!.serverKey, + type: s.sync!.paramType, + canSync: true + })); + +export const SETTINGS_FALLBACK_EXIT_ROUTE = ROUTES.START; + +export { SETTINGS_KEYS } from './settings-keys'; diff --git a/tools/ui/src/lib/constants/storage.ts b/tools/ui/src/lib/constants/storage.ts new file mode 100644 index 000000000..d03254b9c --- /dev/null +++ b/tools/ui/src/lib/constants/storage.ts @@ -0,0 +1,46 @@ +/** + * Storage-related constants (localStorage, IndexedDB). + * + * Centralized to ensure consistency across the app and simplify future + * name changes. + */ + +/** Name prefix for all localStorage keys */ +export const STORAGE_APP_NAME = 'LlamaUi'; + +/** Deprecated localStorage key prefix (old app name) */ +export const STORAGE_APP_NAME_DEPRECATED = 'LlamaCppWebui'; + +/** @deprecated Deprecated IndexedDB name — will be removed after all users have migrated */ +export const DB_APP_NAME_DEPRECATED = 'LlamacppWebui'; + +export const ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.alwaysAllowedTools`; +export const CONFIG_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.config`; +export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTools`; +export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`; +export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpDefaultEnabled`; +export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`; + +// Deprecated old key names (kept for backward compat while users migrate) +/** @deprecated Use {@link ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY} instead */ +export const DEPRECATED_ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME_DEPRECATED}.alwaysAllowedTools`; +/** @deprecated Use {@link CONFIG_LOCALSTORAGE_KEY} instead */ +export const DEPRECATED_CONFIG_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME_DEPRECATED}.config`; +/** @deprecated Use {@link DISABLED_TOOLS_LOCALSTORAGE_KEY} instead */ +export const DEPRECATED_DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME_DEPRECATED}.disabledTools`; +/** @deprecated Use {@link FAVORITE_MODELS_LOCALSTORAGE_KEY} instead */ +export const DEPRECATED_FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME_DEPRECATED}.favoriteModels`; +/** @deprecated Use {@link MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY} instead */ +export const DEPRECATED_MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME_DEPRECATED}.mcpDefaultEnabled`; +/** @deprecated Use {@link USER_OVERRIDES_LOCALSTORAGE_KEY} instead */ +export const DEPRECATED_USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME_DEPRECATED}.userOverrides`; + +/** Maps new keys to their deprecated fallback keys */ +export const NEW_TO_DEPRECATED_MAP: Record = { + [ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY]: DEPRECATED_ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY, + [CONFIG_LOCALSTORAGE_KEY]: DEPRECATED_CONFIG_LOCALSTORAGE_KEY, + [DISABLED_TOOLS_LOCALSTORAGE_KEY]: DEPRECATED_DISABLED_TOOLS_LOCALSTORAGE_KEY, + [FAVORITE_MODELS_LOCALSTORAGE_KEY]: DEPRECATED_FAVORITE_MODELS_LOCALSTORAGE_KEY, + [MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY]: DEPRECATED_MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY, + [USER_OVERRIDES_LOCALSTORAGE_KEY]: DEPRECATED_USER_OVERRIDES_LOCALSTORAGE_KEY +}; diff --git a/tools/ui/src/lib/constants/supported-file-types.ts b/tools/ui/src/lib/constants/supported-file-types.ts new file mode 100644 index 000000000..7fc321e54 --- /dev/null +++ b/tools/ui/src/lib/constants/supported-file-types.ts @@ -0,0 +1,217 @@ +/** + * Comprehensive dictionary of all supported file types in llama-ui + * Organized by category with TypeScript enums for better type safety + */ + +import { + FileExtensionAudio, + FileExtensionImage, + FileExtensionPdf, + FileExtensionText, + FileTypeAudio, + FileTypeImage, + FileTypePdf, + FileTypeText, + MimeTypeAudio, + MimeTypeImage, + MimeTypeApplication, + MimeTypeText +} from '$lib/enums'; + +// File type configuration using enums +export const AUDIO_FILE_TYPES = { + [FileTypeAudio.MP3]: { + extensions: [FileExtensionAudio.MP3], + mimeTypes: [MimeTypeAudio.MP3_MPEG, MimeTypeAudio.MP3] + }, + [FileTypeAudio.WAV]: { + extensions: [FileExtensionAudio.WAV], + mimeTypes: [MimeTypeAudio.WAV] + } +} as const; + +export const IMAGE_FILE_TYPES = { + [FileTypeImage.JPEG]: { + extensions: [FileExtensionImage.JPG, FileExtensionImage.JPEG], + mimeTypes: [MimeTypeImage.JPEG] + }, + [FileTypeImage.PNG]: { + extensions: [FileExtensionImage.PNG], + mimeTypes: [MimeTypeImage.PNG] + }, + [FileTypeImage.GIF]: { + extensions: [FileExtensionImage.GIF], + mimeTypes: [MimeTypeImage.GIF] + }, + [FileTypeImage.WEBP]: { + extensions: [FileExtensionImage.WEBP], + mimeTypes: [MimeTypeImage.WEBP] + }, + [FileTypeImage.SVG]: { + extensions: [FileExtensionImage.SVG], + mimeTypes: [MimeTypeImage.SVG] + } +} as const; + +export const PDF_FILE_TYPES = { + [FileTypePdf.PDF]: { + extensions: [FileExtensionPdf.PDF], + mimeTypes: [MimeTypeApplication.PDF] + } +} as const; + +export const TEXT_FILE_TYPES = { + [FileTypeText.PLAIN_TEXT]: { + extensions: [FileExtensionText.TXT], + mimeTypes: [MimeTypeText.PLAIN] + }, + [FileTypeText.MARKDOWN]: { + extensions: [FileExtensionText.MD], + mimeTypes: [MimeTypeText.MARKDOWN] + }, + [FileTypeText.ASCIIDOC]: { + extensions: [FileExtensionText.ADOC], + mimeTypes: [MimeTypeText.ASCIIDOC] + }, + [FileTypeText.JAVASCRIPT]: { + extensions: [FileExtensionText.JS], + mimeTypes: [MimeTypeText.JAVASCRIPT, MimeTypeText.JAVASCRIPT_APP] + }, + [FileTypeText.TYPESCRIPT]: { + extensions: [FileExtensionText.TS], + mimeTypes: [MimeTypeText.TYPESCRIPT] + }, + [FileTypeText.JSX]: { + extensions: [FileExtensionText.JSX], + mimeTypes: [MimeTypeText.JSX] + }, + [FileTypeText.TSX]: { + extensions: [FileExtensionText.TSX], + mimeTypes: [MimeTypeText.TSX] + }, + [FileTypeText.CSS]: { + extensions: [FileExtensionText.CSS], + mimeTypes: [MimeTypeText.CSS] + }, + [FileTypeText.HTML]: { + extensions: [FileExtensionText.HTML, FileExtensionText.HTM], + mimeTypes: [MimeTypeText.HTML] + }, + [FileTypeText.JSON]: { + extensions: [FileExtensionText.JSON], + mimeTypes: [MimeTypeText.JSON] + }, + [FileTypeText.XML]: { + extensions: [FileExtensionText.XML], + mimeTypes: [MimeTypeText.XML_TEXT, MimeTypeText.XML_APP] + }, + [FileTypeText.YAML]: { + extensions: [FileExtensionText.YAML, FileExtensionText.YML], + mimeTypes: [MimeTypeText.YAML_TEXT, MimeTypeText.YAML_APP] + }, + [FileTypeText.CSV]: { + extensions: [FileExtensionText.CSV], + mimeTypes: [MimeTypeText.CSV] + }, + [FileTypeText.LOG]: { + extensions: [FileExtensionText.LOG], + mimeTypes: [MimeTypeText.PLAIN] + }, + [FileTypeText.PYTHON]: { + extensions: [FileExtensionText.PY], + mimeTypes: [MimeTypeText.PYTHON] + }, + [FileTypeText.JAVA]: { + extensions: [FileExtensionText.JAVA], + mimeTypes: [MimeTypeText.JAVA] + }, + [FileTypeText.CPP]: { + extensions: [ + FileExtensionText.CPP, + FileExtensionText.C, + FileExtensionText.H, + FileExtensionText.HPP + ], + mimeTypes: [MimeTypeText.CPP_SRC, MimeTypeText.CPP_HDR, MimeTypeText.C_SRC, MimeTypeText.C_HDR] + }, + [FileTypeText.PHP]: { + extensions: [FileExtensionText.PHP], + mimeTypes: [MimeTypeText.PHP] + }, + [FileTypeText.RUBY]: { + extensions: [FileExtensionText.RB], + mimeTypes: [MimeTypeText.RUBY] + }, + [FileTypeText.GO]: { + extensions: [FileExtensionText.GO], + mimeTypes: [MimeTypeText.GO] + }, + [FileTypeText.RUST]: { + extensions: [FileExtensionText.RS], + mimeTypes: [MimeTypeText.RUST] + }, + [FileTypeText.SHELL]: { + extensions: [FileExtensionText.SH, FileExtensionText.BAT], + mimeTypes: [MimeTypeText.SHELL, MimeTypeText.BAT] + }, + [FileTypeText.SQL]: { + extensions: [FileExtensionText.SQL], + mimeTypes: [MimeTypeText.SQL] + }, + [FileTypeText.R]: { + extensions: [FileExtensionText.R], + mimeTypes: [MimeTypeText.R] + }, + [FileTypeText.SCALA]: { + extensions: [FileExtensionText.SCALA], + mimeTypes: [MimeTypeText.SCALA] + }, + [FileTypeText.KOTLIN]: { + extensions: [FileExtensionText.KT], + mimeTypes: [MimeTypeText.KOTLIN] + }, + [FileTypeText.SWIFT]: { + extensions: [FileExtensionText.SWIFT], + mimeTypes: [MimeTypeText.SWIFT] + }, + [FileTypeText.DART]: { + extensions: [FileExtensionText.DART], + mimeTypes: [MimeTypeText.DART] + }, + [FileTypeText.VUE]: { + extensions: [FileExtensionText.VUE], + mimeTypes: [MimeTypeText.VUE] + }, + [FileTypeText.SVELTE]: { + extensions: [FileExtensionText.SVELTE], + mimeTypes: [MimeTypeText.SVELTE] + }, + [FileTypeText.LATEX]: { + extensions: [FileExtensionText.TEX], + mimeTypes: [MimeTypeText.LATEX, MimeTypeText.TEX, MimeTypeText.TEX_APP] + }, + [FileTypeText.BIBTEX]: { + extensions: [FileExtensionText.BIB], + mimeTypes: [MimeTypeText.BIBTEX] + }, + [FileTypeText.CUDA]: { + extensions: [FileExtensionText.CU, FileExtensionText.CUH], + mimeTypes: [MimeTypeText.CUDA] + }, + [FileTypeText.VULKAN]: { + extensions: [FileExtensionText.COMP], + mimeTypes: [MimeTypeText.PLAIN] + }, + [FileTypeText.HASKELL]: { + extensions: [FileExtensionText.HS], + mimeTypes: [MimeTypeText.HASKELL] + }, + [FileTypeText.CSHARP]: { + extensions: [FileExtensionText.CS], + mimeTypes: [MimeTypeText.CSHARP] + }, + [FileTypeText.PROPERTIES]: { + extensions: [FileExtensionText.PROPERTIES], + mimeTypes: [MimeTypeText.PROPERTIES] + } +} as const; diff --git a/tools/ui/src/lib/constants/table-html-restorer.ts b/tools/ui/src/lib/constants/table-html-restorer.ts new file mode 100644 index 000000000..e5d5b1201 --- /dev/null +++ b/tools/ui/src/lib/constants/table-html-restorer.ts @@ -0,0 +1,20 @@ +/** + * Matches
              ,
              ,
              tags (case-insensitive). + * Used to detect line breaks in table cell text content. + */ +export const BR_PATTERN = //gi; + +/** + * Matches a complete
                ...
              block. + * Captures the inner content (group 1) for further
            • extraction. + * Case-insensitive, allows multiline content. + */ +export const LIST_PATTERN = /^
                ([\s\S]*)<\/ul>$/i; + +/** + * Matches individual
              • ...
              • elements within a list. + * Captures the inner content (group 1) of each list item. + * Non-greedy to handle multiple consecutive items. + * Case-insensitive, allows multiline content. + */ +export const LI_PATTERN = /
              • ([\s\S]*?)<\/li>/gi; diff --git a/tools/ui/src/lib/constants/title-generation.ts b/tools/ui/src/lib/constants/title-generation.ts new file mode 100644 index 000000000..48ca2217a --- /dev/null +++ b/tools/ui/src/lib/constants/title-generation.ts @@ -0,0 +1,9 @@ +/* Title generation constants */ +export const TITLE_GENERATION = { + MIN_LENGTH: 3, + FALLBACK: 'New Chat', + DEFAULT_PROMPT: + 'Based on the following interaction, generate a short, concise title (maximum 6-8 words) that captures the main topic. Return ONLY the title text, nothing else. Do not use quotes.\n\nUser: {{USER}}\n\nAssistant: {{ASSISTANT}}\n\nTitle:', + PREFIX_PATTERN: /^(Title:|Subject:|Topic:)\s*/i, + QUOTE_PATTERN: /^["]|["]$/g +} as const; diff --git a/tools/ui/src/lib/constants/tools.ts b/tools/ui/src/lib/constants/tools.ts new file mode 100644 index 000000000..22b22309c --- /dev/null +++ b/tools/ui/src/lib/constants/tools.ts @@ -0,0 +1,11 @@ +import { ToolSource } from '$lib/enums/tools'; + +export const TOOL_GROUP_LABELS = { + [ToolSource.BUILTIN]: 'Built-in', + [ToolSource.CUSTOM]: 'JSON Schema' +} as const; + +export const TOOL_SERVER_LABELS = { + [ToolSource.BUILTIN]: 'Built-in Tools', + [ToolSource.CUSTOM]: 'Custom Tools' +} as const; diff --git a/tools/ui/src/lib/constants/tooltip-config.ts b/tools/ui/src/lib/constants/tooltip-config.ts new file mode 100644 index 000000000..ad76ab352 --- /dev/null +++ b/tools/ui/src/lib/constants/tooltip-config.ts @@ -0,0 +1 @@ +export const TOOLTIP_DELAY_DURATION = 500; diff --git a/tools/ui/src/lib/constants/ui.ts b/tools/ui/src/lib/constants/ui.ts new file mode 100644 index 000000000..f6e7f7d8a --- /dev/null +++ b/tools/ui/src/lib/constants/ui.ts @@ -0,0 +1,37 @@ +import { Settings, Search, SquarePen } from '@lucide/svelte'; +import McpLogo from '$lib/components/app/mcp/McpLogo.svelte'; +import type { Component } from 'svelte'; +import { ROUTES } from './routes'; + +export const FORK_TREE_DEPTH_PADDING = 8; +export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message'; +export const APP_NAME = import.meta.env.VITE_PUBLIC_APP_NAME || 'llama-ui'; + +export const ICON_STRIP_TRANSITION_DURATION = 150; +export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50; + +export interface DesktopIconStripItem { + icon: Component; + tooltip: string; + route?: string; + activeRouteId?: string; + activeRoutePrefix?: string; + keys?: string[]; +} + +export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [ + { icon: SquarePen, tooltip: 'New chat', route: ROUTES.NEW_CHAT, keys: ['shift', 'cmd', 'o'] }, + { icon: Search, tooltip: 'Search', keys: ['cmd', 'k'] }, + { + icon: McpLogo, + tooltip: 'MCP Servers', + route: ROUTES.MCP_SERVERS, + activeRouteId: '/mcp-servers' + }, + { + icon: Settings, + tooltip: 'Settings', + route: ROUTES.SETTINGS, + activeRoutePrefix: '/settings' + } +]; diff --git a/tools/ui/src/lib/constants/uri-template.ts b/tools/ui/src/lib/constants/uri-template.ts new file mode 100644 index 000000000..dc834aca2 --- /dev/null +++ b/tools/ui/src/lib/constants/uri-template.ts @@ -0,0 +1,57 @@ +/** + * URI Template constants for RFC 6570 template processing. + */ + +/** URI scheme separator */ +export const URI_SCHEME_SEPARATOR = '://'; + +/** Regex to match template expressions like {var}, {+var}, {#var}, {/var} */ +export const TEMPLATE_EXPRESSION_REGEX = /\{([+#./;?&]?)([^}]+)\}/g; + +/** RFC 6570 URI template operators */ +export const URI_TEMPLATE_OPERATORS = { + /** Simple string expansion (default) */ + SIMPLE: '', + /** Reserved expansion */ + RESERVED: '+', + /** Fragment expansion */ + FRAGMENT: '#', + /** Path segment expansion */ + PATH_SEGMENT: '/', + /** Label expansion */ + LABEL: '.', + /** Path-style parameters */ + PATH_PARAM: ';', + /** Form-style query */ + FORM_QUERY: '?', + /** Form-style query continuation */ + FORM_CONTINUATION: '&' +} as const; + +/** URI template separators used in expansion */ +export const URI_TEMPLATE_SEPARATORS = { + /** Comma separator for list expansion */ + COMMA: ',', + /** Slash separator for path segments */ + SLASH: '/', + /** Period separator for label expansion */ + PERIOD: '.', + /** Semicolon separator for path parameters */ + SEMICOLON: ';', + /** Question mark prefix for query string */ + QUERY_PREFIX: '?', + /** Ampersand prefix for query continuation */ + QUERY_CONTINUATION: '&' +} as const; + +/** Maximum number of leading slashes to strip during URI normalization */ +export const MAX_LEADING_SLASHES_TO_STRIP = 3; + +/** Regex to strip explode modifier (*) from variable names */ +export const VARIABLE_EXPLODE_MODIFIER_REGEX = /[*]$/; + +/** Regex to strip prefix modifier (:N) from variable names */ +export const VARIABLE_PREFIX_MODIFIER_REGEX = /:[\d]+$/; + +/** Regex to strip one or more leading slashes */ +export const LEADING_SLASHES_REGEX = /^\/+/; diff --git a/tools/ui/src/lib/constants/url.ts b/tools/ui/src/lib/constants/url.ts new file mode 100644 index 000000000..0afb9decc --- /dev/null +++ b/tools/ui/src/lib/constants/url.ts @@ -0,0 +1,186 @@ +const STD = ['com', 'net', 'org', 'gov', 'edu'] as const; + +const STD_MIL = [...STD, 'mil'] as const; + +const ccTLD_PREFIXES: Record = { + // --- Standard 5 only --- + ar: STD, + bd: STD, + bg: STD, + cn: STD_MIL, + eg: STD, + gr: STD, + hk: STD, + hr: STD, + lk: STD, + mx: STD_MIL, + my: STD_MIL, + ng: STD, + ph: STD, + pk: STD, + pl: STD, + ro: STD, + ru: STD, + sa: STD, + si: STD, + tr: STD, + tw: STD, + ua: STD, + ve: STD, + + au: [...STD_MIL, 'id', 'asn', 'csiro'], + br: [ + ...STD_MIL, + 'art', + 'eco', + 'eng', + 'inf', + 'med', + 'psi', + 'tmp', + 'etc', + 'adm', + 'adv', + 'arq', + 'bio', + 'bmd', + 'cim', + 'cng', + 'cnt', + 'coop', + 'ecn', + 'esp', + 'far', + 'fm', + 'fnd', + 'fot', + 'fst', + 'g12', + 'ggf', + 'imb', + 'ind', + 'jor', + 'jus', + 'leg', + 'lel', + 'mat', + 'mp', + 'mus', + 'not', + 'ntr', + 'odo', + 'ppg', + 'pro', + 'psc', + 'qsl', + 'rec', + 'slg', + 'srv', + 'trd', + 'tur', + 'tv', + 'vet', + 'vlog', + 'wiki', + 'zlg' + ], + id: [...STD_MIL, 'co', 'go', 'or', 'web', 'sch'], + in: [...STD_MIL, 'co', 'gen', 'ind', 'firm', 'ernet', 'nic'], + kr: [...STD_MIL, 'co', 'go', 'or', 'ac', 're'], + nz: [ + ...STD_MIL, + 'co', + 'gen', + 'geek', + 'kiwi', + 'maori', + 'school', + 'govt', + 'health', + 'iwi', + 'parliament' + ], + sg: [...STD, 'per'], + th: ['co', 'go', 'or', 'in', 'ac', 'mi', 'net'], + + ae: ['co', 'net', 'org', 'gov', 'ac', 'sch'], + hu: ['co', 'net', 'org', 'gov', 'edu'], + il: ['co', 'net', 'org', 'gov', 'ac', 'muni'], + jp: ['ac', 'ad', 'co', 'ed', 'go', 'gr', 'lg', 'ne', 'or'], + ke: ['co', 'or', 'ne', 'go', 'ac', 'sc'], + rs: ['co', 'net', 'org', 'gov', 'edu'], + uk: ['co', 'org', 'net', 'ac', 'gov', 'mil', 'nhs', 'police', 'mod', 'ltd', 'plc', 'me', 'sch'], + za: ['co', 'org', 'net', 'web', 'law', 'mil'] +}; + +const WILDCARD_BASES: Record = { + br: ['nom', 'blog'], + jp: [ + 'kobe', + 'kyoto', + 'nagoya', + 'osaka', + 'sapporo', + 'sendai', + 'tokyo', + 'yokohama', + 'aichi', + 'akita', + 'aomori', + 'chiba', + 'ehime', + 'fukui', + 'fukuoka', + 'fukushima', + 'gifu', + 'gunma', + 'hiroshima', + 'hokkaido', + 'hyogo', + 'ibaraki', + 'ishikawa', + 'iwate', + 'kagawa', + 'kagoshima', + 'kanagawa', + 'kochi', + 'kumamoto', + 'mie', + 'miyagi', + 'miyazaki', + 'nagano', + 'nara', + 'niigata', + 'oita', + 'okayama', + 'okinawa', + 'saga', + 'saitama', + 'shiga', + 'shimane', + 'shizuoka', + 'tochigi', + 'tokushima', + 'tottori', + 'toyama', + 'wakayama', + 'yamagata', + 'yamaguchi', + 'yamanashi' + ] +}; + +function buildSuffixSet(suffixes: Record): Set { + const set = new Set(); + + for (const [tld, parts] of Object.entries(suffixes)) { + for (const part of parts) { + set.add(`${part}.${tld}`); + } + } + + return set; +} + +export const TWO_PART_PUBLIC_SUFFIXES = buildSuffixSet(ccTLD_PREFIXES); +export const WILDCARD_PUBLIC_SUFFIXES = buildSuffixSet(WILDCARD_BASES); diff --git a/tools/ui/src/lib/constants/viewport.ts b/tools/ui/src/lib/constants/viewport.ts new file mode 100644 index 000000000..26e202cfe --- /dev/null +++ b/tools/ui/src/lib/constants/viewport.ts @@ -0,0 +1 @@ +export const DEFAULT_MOBILE_BREAKPOINT = 768; diff --git a/tools/ui/src/lib/contexts/chat-actions.context.ts b/tools/ui/src/lib/contexts/chat-actions.context.ts new file mode 100644 index 000000000..e9050fa27 --- /dev/null +++ b/tools/ui/src/lib/contexts/chat-actions.context.ts @@ -0,0 +1,39 @@ +import { getContext, setContext } from 'svelte'; +import { CONTEXT_KEY_CHAT_ACTIONS } from '$lib/constants'; + +export interface ChatActionsContext { + copy: (message: DatabaseMessage) => void; + delete: (message: DatabaseMessage) => void; + navigateToSibling: (siblingId: string) => void; + editWithBranching: ( + message: DatabaseMessage, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ) => void; + editWithReplacement: ( + message: DatabaseMessage, + newContent: string, + shouldBranch: boolean + ) => void; + editUserMessagePreserveResponses: ( + message: DatabaseMessage, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ) => void; + regenerateWithBranching: (message: DatabaseMessage, modelOverride?: string) => void; + continueAssistantMessage: (message: DatabaseMessage) => void; + forkConversation: ( + message: DatabaseMessage, + options: { name: string; includeAttachments: boolean } + ) => void; +} + +const CHAT_ACTIONS_KEY = Symbol.for(CONTEXT_KEY_CHAT_ACTIONS); + +export function setChatActionsContext(ctx: ChatActionsContext): ChatActionsContext { + return setContext(CHAT_ACTIONS_KEY, ctx); +} + +export function getChatActionsContext(): ChatActionsContext { + return getContext(CHAT_ACTIONS_KEY); +} diff --git a/tools/ui/src/lib/contexts/chat-settings-config.context.ts b/tools/ui/src/lib/contexts/chat-settings-config.context.ts new file mode 100644 index 000000000..35941e09b --- /dev/null +++ b/tools/ui/src/lib/contexts/chat-settings-config.context.ts @@ -0,0 +1,20 @@ +import { getContext, setContext } from 'svelte'; +import { CONTEXT_KEY_CHAT_SETTINGS_CONFIG } from '$lib/constants'; + +export interface ChatSettingsConfigContext { + readonly localConfig: SettingsConfigType; + handleConfigChange: (key: string, value: string | boolean) => void; + handleThemeChange: (theme: string) => void; +} + +const CHAT_SETTINGS_CONFIG_KEY = Symbol.for(CONTEXT_KEY_CHAT_SETTINGS_CONFIG); + +export function setChatSettingsConfigContext( + ctx: ChatSettingsConfigContext +): ChatSettingsConfigContext { + return setContext(CHAT_SETTINGS_CONFIG_KEY, ctx); +} + +export function getChatSettingsConfigContext(): ChatSettingsConfigContext { + return getContext(CHAT_SETTINGS_CONFIG_KEY); +} diff --git a/tools/ui/src/lib/contexts/index.ts b/tools/ui/src/lib/contexts/index.ts new file mode 100644 index 000000000..01cd1d4b7 --- /dev/null +++ b/tools/ui/src/lib/contexts/index.ts @@ -0,0 +1,25 @@ +export { + getMessageEditContext, + setMessageEditContext, + type MessageEditContext, + type MessageEditState, + type MessageEditActions +} from './message-edit.context'; + +export { + getChatActionsContext, + setChatActionsContext, + type ChatActionsContext +} from './chat-actions.context'; + +export { + getChatSettingsConfigContext, + setChatSettingsConfigContext, + type ChatSettingsConfigContext +} from './chat-settings-config.context'; + +export { + getProcessingInfoContext, + setProcessingInfoContext, + type ProcessingInfoContext +} from './processing-info.context'; diff --git a/tools/ui/src/lib/contexts/message-edit.context.ts b/tools/ui/src/lib/contexts/message-edit.context.ts new file mode 100644 index 000000000..b6231f940 --- /dev/null +++ b/tools/ui/src/lib/contexts/message-edit.context.ts @@ -0,0 +1,51 @@ +import { getContext, setContext } from 'svelte'; +import { CONTEXT_KEY_MESSAGE_EDIT } from '$lib/constants'; +import { MessageRole } from '$lib/enums'; + +export interface MessageEditState { + readonly isEditing: boolean; + readonly editedContent: string; + readonly editedExtras: DatabaseMessageExtra[]; + readonly editedUploadedFiles: ChatUploadedFile[]; + readonly originalContent: string; + readonly originalExtras: DatabaseMessageExtra[]; + readonly showSaveOnlyOption: boolean; + readonly showBranchAfterEditOption: boolean; + readonly shouldBranchAfterEdit: boolean; + readonly messageRole: MessageRole; + readonly rawEditContent?: string; +} + +export interface MessageEditActions { + setContent: (content: string) => void; + setExtras: (extras: DatabaseMessageExtra[]) => void; + setUploadedFiles: (files: ChatUploadedFile[]) => void; + save: () => void; + saveOnly: () => void; + cancel: () => void; + startEdit: () => void; +} + +export interface AssistantEditActions { + setShouldBranchAfterEdit: (value: boolean) => void; +} + +export type MessageEditContext = MessageEditState & + MessageEditActions & + Partial; + +const MESSAGE_EDIT_KEY = Symbol.for(CONTEXT_KEY_MESSAGE_EDIT); + +/** + * Sets the message edit context. Call this in the parent component (ChatMessage.svelte). + */ +export function setMessageEditContext(ctx: MessageEditContext): MessageEditContext { + return setContext(MESSAGE_EDIT_KEY, ctx); +} + +/** + * Gets the message edit context. Call this in child components. + */ +export function getMessageEditContext(): MessageEditContext { + return getContext(MESSAGE_EDIT_KEY); +} diff --git a/tools/ui/src/lib/contexts/processing-info.context.ts b/tools/ui/src/lib/contexts/processing-info.context.ts new file mode 100644 index 000000000..0cf43336f --- /dev/null +++ b/tools/ui/src/lib/contexts/processing-info.context.ts @@ -0,0 +1,16 @@ +import { getContext, setContext } from 'svelte'; +import { CONTEXT_KEY_PROCESSING_INFO } from '$lib/constants'; + +export interface ProcessingInfoContext { + readonly showProcessingInfo: boolean; +} + +const PROCESSING_INFO_KEY = Symbol.for(CONTEXT_KEY_PROCESSING_INFO); + +export function setProcessingInfoContext(ctx: ProcessingInfoContext): ProcessingInfoContext { + return setContext(PROCESSING_INFO_KEY, ctx); +} + +export function getProcessingInfoContext(): ProcessingInfoContext { + return getContext(PROCESSING_INFO_KEY); +} diff --git a/tools/ui/src/lib/enums/agentic.ts b/tools/ui/src/lib/enums/agentic.ts new file mode 100644 index 000000000..b96d244cd --- /dev/null +++ b/tools/ui/src/lib/enums/agentic.ts @@ -0,0 +1,18 @@ +/** + * OpenAI-compatible tool call type. + */ +export enum ToolCallType { + FUNCTION = 'function' +} + +/** + * Types of sections in agentic content display. + */ +export enum AgenticSectionType { + TEXT = 'text', + TOOL_CALL = 'tool_call', + TOOL_CALL_PENDING = 'tool_call_pending', + TOOL_CALL_STREAMING = 'tool_call_streaming', + REASONING = 'reasoning', + REASONING_PENDING = 'reasoning_pending' +} diff --git a/tools/ui/src/lib/enums/attachment.ts b/tools/ui/src/lib/enums/attachment.ts new file mode 100644 index 000000000..49baf6bae --- /dev/null +++ b/tools/ui/src/lib/enums/attachment.ts @@ -0,0 +1,53 @@ +/** + * Attachment type enum for database message extras + */ +export enum AttachmentType { + AUDIO = 'AUDIO', + IMAGE = 'IMAGE', + MCP_PROMPT = 'MCP_PROMPT', + MCP_RESOURCE = 'MCP_RESOURCE', + PDF = 'PDF', + TEXT = 'TEXT', + LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility +} + +/** + * Unique identifiers for attachment menu items in the chat form action dropdowns. + * Used to select which file upload or attachment action is triggered. + */ +export enum AttachmentMenuItemId { + IMAGES = 'images', + AUDIO = 'audio', + TEXT = 'text', + PDF = 'pdf', + SYSTEM_MESSAGE = 'system-message', + MCP_PROMPT = 'mcp-prompt', + MCP_RESOURCES = 'mcp-resources' +} + +/** + * Defines when an attachment menu item should be enabled. + */ +export enum AttachmentItemEnabledWhen { + ALWAYS = 'always', + HAS_VISION_MODALITY = 'hasVisionModality', + HAS_AUDIO_MODALITY = 'hasAudioModality' +} + +/** + * Defines the callback action triggered when an attachment menu item is clicked. + */ +export enum AttachmentAction { + FILE_UPLOAD = 'onFileUpload', + SYSTEM_PROMPT_CLICK = 'onSystemPromptClick', + MCP_PROMPT_CLICK = 'onMcpPromptClick', + MCP_RESOURCES_CLICK = 'onMcpResourcesClick' +} + +/** + * Visibility conditions for attachment menu items. + */ +export enum AttachmentItemVisibleWhen { + HAS_MCP_PROMPTS_SUPPORT = 'hasMcpPromptsSupport', + HAS_MCP_RESOURCES_SUPPORT = 'hasMcpResourcesSupport' +} diff --git a/tools/ui/src/lib/enums/chat.ts b/tools/ui/src/lib/enums/chat.ts new file mode 100644 index 000000000..ff67436ab --- /dev/null +++ b/tools/ui/src/lib/enums/chat.ts @@ -0,0 +1,64 @@ +export enum ChatMessageStatsView { + GENERATION = 'generation', + READING = 'reading', + TOOLS = 'tools', + SUMMARY = 'summary' +} + +/** + * Reasoning format options for API requests. + */ +export enum ReasoningFormat { + NONE = 'none', + AUTO = 'auto' +} + +/** + * Message roles for chat messages. + */ +export enum MessageRole { + USER = 'user', + ASSISTANT = 'assistant', + SYSTEM = 'system', + TOOL = 'tool' +} + +/** + * Message types for different content kinds. + */ +export enum MessageType { + ROOT = 'root', + TEXT = 'text', + THINK = 'think', + SYSTEM = 'system' +} + +/** + * Content part types for API chat message content. + */ +export enum ContentPartType { + TEXT = 'text', + IMAGE_URL = 'image_url', + INPUT_AUDIO = 'input_audio' +} + +/** + * Error dialog types for displaying server/timeout errors. + */ +export enum ErrorDialogType { + TIMEOUT = 'timeout', + SERVER = 'server' +} + +export enum ConversationSelectionMode { + EXPORT = 'export', + IMPORT = 'import' +} + +/** + * PDF view mode options for previewing PDF attachments. + */ +export enum PdfViewMode { + TEXT = 'text', + PAGES = 'pages' +} diff --git a/tools/ui/src/lib/enums/files.ts b/tools/ui/src/lib/enums/files.ts new file mode 100644 index 000000000..cf081485c --- /dev/null +++ b/tools/ui/src/lib/enums/files.ts @@ -0,0 +1,235 @@ +/** + * Comprehensive dictionary of all supported file types in llama-ui + * Organized by category with TypeScript enums for better type safety + */ + +// File type category enum +export enum FileTypeCategory { + IMAGE = 'image', + AUDIO = 'audio', + PDF = 'pdf', + TEXT = 'text' +} + +/** + * Special file types for internal use (not MIME types) + */ +export enum SpecialFileType { + MCP_PROMPT = 'mcp-prompt' +} + +// Specific file type enums for each category +export enum FileTypeImage { + JPEG = 'jpeg', + PNG = 'png', + GIF = 'gif', + WEBP = 'webp', + SVG = 'svg' +} + +export enum FileTypeAudio { + MP3 = 'mp3', + WAV = 'wav', + WEBM = 'webm' +} + +export enum FileTypePdf { + PDF = 'pdf' +} + +export enum FileTypeText { + PLAIN_TEXT = 'plainText', + MARKDOWN = 'md', + ASCIIDOC = 'asciidoc', + JAVASCRIPT = 'js', + TYPESCRIPT = 'ts', + JSX = 'jsx', + TSX = 'tsx', + CSS = 'css', + HTML = 'html', + JSON = 'json', + XML = 'xml', + YAML = 'yaml', + CSV = 'csv', + LOG = 'log', + PYTHON = 'python', + JAVA = 'java', + CPP = 'cpp', + PHP = 'php', + RUBY = 'ruby', + GO = 'go', + RUST = 'rust', + SHELL = 'shell', + SQL = 'sql', + R = 'r', + SCALA = 'scala', + KOTLIN = 'kotlin', + SWIFT = 'swift', + DART = 'dart', + VUE = 'vue', + SVELTE = 'svelte', + LATEX = 'latex', + BIBTEX = 'bibtex', + CUDA = 'cuda', + VULKAN = 'vulkan', + HASKELL = 'haskell', + CSHARP = 'csharp', + PROPERTIES = 'properties' +} + +// File extension enums +export enum FileExtensionImage { + JPG = '.jpg', + JPEG = '.jpeg', + PNG = '.png', + GIF = '.gif', + WEBP = '.webp', + SVG = '.svg' +} + +export enum FileExtensionAudio { + MP3 = '.mp3', + WAV = '.wav' +} + +export enum FileExtensionPdf { + PDF = '.pdf' +} + +export enum FileExtensionText { + TXT = '.txt', + MD = '.md', + ADOC = '.adoc', + JS = '.js', + TS = '.ts', + JSX = '.jsx', + TSX = '.tsx', + CSS = '.css', + HTML = '.html', + HTM = '.htm', + JSON = '.json', + XML = '.xml', + YAML = '.yaml', + YML = '.yml', + CSV = '.csv', + LOG = '.log', + PY = '.py', + JAVA = '.java', + CPP = '.cpp', + C = '.c', + H = '.h', + PHP = '.php', + RB = '.rb', + GO = '.go', + RS = '.rs', + SH = '.sh', + BAT = '.bat', + SQL = '.sql', + R = '.r', + SCALA = '.scala', + KT = '.kt', + SWIFT = '.swift', + DART = '.dart', + VUE = '.vue', + SVELTE = '.svelte', + TEX = '.tex', + BIB = '.bib', + CU = '.cu', + CUH = '.cuh', + COMP = '.comp', + HPP = '.hpp', + HS = '.hs', + PROPERTIES = '.properties', + CS = '.cs' +} + +// MIME type prefixes and includes for content detection +export enum MimeTypePrefix { + IMAGE = 'image/', + TEXT = 'text' +} + +export enum MimeTypeIncludes { + JSON = 'json', + JAVASCRIPT = 'javascript', + TYPESCRIPT = 'typescript' +} + +// URI patterns for content detection +export enum UriPattern { + DATABASE_KEYWORD = 'database', + DATABASE_SCHEME = 'db://' +} + +// MIME type enums +export enum MimeTypeApplication { + PDF = 'application/pdf', + OCTET_STREAM = 'application/octet-stream' +} + +export enum MimeTypeAudio { + MP3_MPEG = 'audio/mpeg', + MP3 = 'audio/mp3', + MP4 = 'audio/mp4', + WAV = 'audio/wav', + WEBM = 'audio/webm', + WEBM_OPUS = 'audio/webm;codecs=opus' +} + +export enum MimeTypeImage { + JPEG = 'image/jpeg', + JPG = 'image/jpg', + PNG = 'image/png', + GIF = 'image/gif', + WEBP = 'image/webp', + SVG = 'image/svg+xml', + ICO = 'image/x-icon', + ICO_MICROSOFT = 'image/vnd.microsoft.icon' +} + +export enum MimeTypeText { + PLAIN = 'text/plain', + MARKDOWN = 'text/markdown', + ASCIIDOC = 'text/asciidoc', + JAVASCRIPT = 'text/javascript', + JAVASCRIPT_APP = 'application/javascript', + TYPESCRIPT = 'text/typescript', + JSX = 'text/jsx', + TSX = 'text/tsx', + CSS = 'text/css', + HTML = 'text/html', + JSON = 'application/json', + XML_TEXT = 'text/xml', + XML_APP = 'application/xml', + YAML_TEXT = 'text/yaml', + YAML_APP = 'application/yaml', + CSV = 'text/csv', + PYTHON = 'text/x-python', + JAVA = 'text/x-java-source', + CPP_HDR = 'text/x-c++hdr', + CPP_SRC = 'text/x-c++src', + CSHARP = 'text/x-csharp', + HASKELL = 'text/x-haskell', + C_SRC = 'text/x-csrc', + C_HDR = 'text/x-chdr', + PHP = 'text/x-php', + RUBY = 'text/x-ruby', + GO = 'text/x-go', + RUST = 'text/x-rust', + SHELL = 'text/x-shellscript', + BAT = 'application/x-bat', + SQL = 'text/x-sql', + R = 'text/x-r', + SCALA = 'text/x-scala', + KOTLIN = 'text/x-kotlin', + SWIFT = 'text/x-swift', + DART = 'text/x-dart', + VUE = 'text/x-vue', + SVELTE = 'text/x-svelte', + TEX = 'text/x-tex', + TEX_APP = 'application/x-tex', + LATEX = 'application/x-latex', + BIBTEX = 'text/x-bibtex', + CUDA = 'text/x-cuda', + PROPERTIES = 'text/properties' +} diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts new file mode 100644 index 000000000..56e1d9f4a --- /dev/null +++ b/tools/ui/src/lib/enums/index.ts @@ -0,0 +1,62 @@ +export { + AttachmentType, + AttachmentMenuItemId, + AttachmentItemEnabledWhen, + AttachmentAction, + AttachmentItemVisibleWhen +} from './attachment'; + +export { AgenticSectionType, ToolCallType } from './agentic'; + +export { + ChatMessageStatsView, + ContentPartType, + ConversationSelectionMode, + ErrorDialogType, + MessageRole, + MessageType, + PdfViewMode, + ReasoningFormat +} from './chat'; + +export { + FileTypeCategory, + FileTypeImage, + FileTypeAudio, + FileTypePdf, + FileTypeText, + FileExtensionImage, + FileExtensionAudio, + FileExtensionPdf, + FileExtensionText, + MimeTypePrefix, + MimeTypeIncludes, + UriPattern, + MimeTypeApplication, + MimeTypeAudio, + MimeTypeImage, + MimeTypeText, + SpecialFileType +} from './files'; + +export { + MCPConnectionPhase, + MCPLogLevel, + MCPTransportType, + HealthCheckStatus, + MCPContentType, + MCPRefType, + JsonSchemaType +} from './mcp'; + +export { ModelModality } from './model'; + +export { ServerRole, ServerModelStatus } from './server'; + +export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings'; + +export { ColorMode, HtmlInputType, McpPromptVariant, TooltipSide, UrlProtocol } from './ui'; + +export { KeyboardKey } from './keyboard'; + +export { ToolSource, ToolPermissionDecision, ToolResponseField } from './tools'; diff --git a/tools/ui/src/lib/enums/keyboard.ts b/tools/ui/src/lib/enums/keyboard.ts new file mode 100644 index 000000000..46cd4a776 --- /dev/null +++ b/tools/ui/src/lib/enums/keyboard.ts @@ -0,0 +1,19 @@ +/** + * Keyboard key names for event handling + */ +export enum KeyboardKey { + ENTER = 'Enter', + ESCAPE = 'Escape', + ARROW_UP = 'ArrowUp', + ARROW_DOWN = 'ArrowDown', + ARROW_LEFT = 'ArrowLeft', + ARROW_RIGHT = 'ArrowRight', + TAB = 'Tab', + D_LOWER = 'd', + D_UPPER = 'D', + E_UPPER = 'E', + K_LOWER = 'k', + O_LOWER = 'o', + O_UPPER = 'O', + SPACE = ' ' +} diff --git a/tools/ui/src/lib/enums/mcp.ts b/tools/ui/src/lib/enums/mcp.ts new file mode 100644 index 000000000..d2c27e1a0 --- /dev/null +++ b/tools/ui/src/lib/enums/mcp.ts @@ -0,0 +1,66 @@ +/** + * Connection lifecycle phases for MCP protocol + */ +export enum MCPConnectionPhase { + IDLE = 'idle', + TRANSPORT_CREATING = 'transport_creating', + TRANSPORT_READY = 'transport_ready', + INITIALIZING = 'initializing', + CAPABILITIES_EXCHANGED = 'capabilities_exchanged', + LISTING_TOOLS = 'listing_tools', + CONNECTED = 'connected', + ERROR = 'error', + DISCONNECTED = 'disconnected' +} + +/** + * Log level for connection events + */ +export enum MCPLogLevel { + INFO = 'info', + WARN = 'warn', + ERROR = 'error' +} + +/** + * Transport types for MCP connections + */ +export enum MCPTransportType { + WEBSOCKET = 'websocket', + STREAMABLE_HTTP = 'streamable_http', + SSE = 'sse' +} + +/** + * Health check status for MCP servers + */ +export enum HealthCheckStatus { + IDLE = 'idle', + CONNECTING = 'connecting', + SUCCESS = 'success', + ERROR = 'error' +} + +/** + * Content types for MCP tool results + */ +export enum MCPContentType { + TEXT = 'text', + IMAGE = 'image', + RESOURCE = 'resource' +} + +/** + * JSON Schema types used in MCP tool definitions + */ +export enum JsonSchemaType { + OBJECT = 'object' +} + +/** + * Reference types for MCP completions + */ +export enum MCPRefType { + PROMPT = 'ref/prompt', + RESOURCE = 'ref/resource' +} diff --git a/tools/ui/src/lib/enums/model.ts b/tools/ui/src/lib/enums/model.ts new file mode 100644 index 000000000..7729ecfea --- /dev/null +++ b/tools/ui/src/lib/enums/model.ts @@ -0,0 +1,5 @@ +export enum ModelModality { + TEXT = 'TEXT', + AUDIO = 'AUDIO', + VISION = 'VISION' +} diff --git a/tools/ui/src/lib/enums/server.ts b/tools/ui/src/lib/enums/server.ts new file mode 100644 index 000000000..c9d599c52 --- /dev/null +++ b/tools/ui/src/lib/enums/server.ts @@ -0,0 +1,21 @@ +/** + * Server role enum - used for single/multi-model mode + */ +export enum ServerRole { + /** Single model mode - server running with a specific model loaded */ + MODEL = 'model', + /** Router mode - server managing multiple model instances */ + ROUTER = 'router' +} + +/** + * Model status enum - matches tools/server/server-models.h from C++ server + * Used as the `value` field in the status object from /models endpoint + */ +export enum ServerModelStatus { + UNLOADED = 'unloaded', + LOADING = 'loading', + LOADED = 'loaded', + SLEEPING = 'sleeping', + FAILED = 'failed' +} diff --git a/tools/ui/src/lib/enums/settings.ts b/tools/ui/src/lib/enums/settings.ts new file mode 100644 index 000000000..f17f21976 --- /dev/null +++ b/tools/ui/src/lib/enums/settings.ts @@ -0,0 +1,26 @@ +/** + * Parameter source - indicates whether a parameter uses default or custom value + */ +export enum ParameterSource { + DEFAULT = 'default', + CUSTOM = 'custom' +} + +/** + * Syncable parameter type - data types for parameters that can be synced with server + */ +export enum SyncableParameterType { + NUMBER = 'number', + STRING = 'string', + BOOLEAN = 'boolean' +} + +/** + * Settings field type - defines the input type for settings fields + */ +export enum SettingsFieldType { + INPUT = 'input', + TEXTAREA = 'textarea', + CHECKBOX = 'checkbox', + SELECT = 'select' +} diff --git a/tools/ui/src/lib/enums/tools.ts b/tools/ui/src/lib/enums/tools.ts new file mode 100644 index 000000000..4b2cdab32 --- /dev/null +++ b/tools/ui/src/lib/enums/tools.ts @@ -0,0 +1,17 @@ +export enum ToolSource { + BUILTIN = 'builtin', + MCP = 'mcp', + CUSTOM = 'custom' +} + +export enum ToolPermissionDecision { + ALWAYS = 'always', + ALWAYS_SERVER = 'always_server', + ONCE = 'once', + DENY = 'deny' +} + +export enum ToolResponseField { + PLAIN_TEXT = 'plain_text_response', + ERROR = 'error' +} diff --git a/tools/ui/src/lib/enums/ui.ts b/tools/ui/src/lib/enums/ui.ts new file mode 100644 index 000000000..829963794 --- /dev/null +++ b/tools/ui/src/lib/enums/ui.ts @@ -0,0 +1,35 @@ +export enum ColorMode { + LIGHT = 'light', + DARK = 'dark', + SYSTEM = 'system' +} + +export enum TooltipSide { + TOP = 'top', + RIGHT = 'right', + BOTTOM = 'bottom', + LEFT = 'left' +} + +/** + * MCP prompt display variant + */ +export enum McpPromptVariant { + MESSAGE = 'message', + ATTACHMENT = 'attachment' +} + +/** + * URL prefixes for protocol detection + */ +export enum UrlProtocol { + DATA = 'data:', + HTTP = 'http:', + HTTPS = 'https:', + WEBSOCKET = 'ws:', + WEBSOCKET_SECURE = 'wss:' +} + +export enum HtmlInputType { + FILE = 'file' +} diff --git a/tools/ui/src/lib/hooks/is-mobile.svelte.ts b/tools/ui/src/lib/hooks/is-mobile.svelte.ts new file mode 100644 index 000000000..6454fc5b5 --- /dev/null +++ b/tools/ui/src/lib/hooks/is-mobile.svelte.ts @@ -0,0 +1,8 @@ +import { DEFAULT_MOBILE_BREAKPOINT } from '$lib/constants'; +import { MediaQuery } from 'svelte/reactivity'; + +export class IsMobile extends MediaQuery { + constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { + super(`max-width: ${breakpoint - 1}px`); + } +} diff --git a/tools/ui/src/lib/hooks/use-attachment-menu.svelte.ts b/tools/ui/src/lib/hooks/use-attachment-menu.svelte.ts new file mode 100644 index 000000000..ddb999485 --- /dev/null +++ b/tools/ui/src/lib/hooks/use-attachment-menu.svelte.ts @@ -0,0 +1,81 @@ +import { page } from '$app/state'; +import { AttachmentAction } from '$lib/enums'; + +export interface AttachmentModalityFlags { + hasVisionModality: boolean; + hasAudioModality: boolean; + hasMcpPromptsSupport: boolean; + hasMcpResourcesSupport: boolean; +} + +export interface AttachmentActionCallbacks { + onFileUpload?: () => void; + onSystemPromptClick?: () => void; + onMcpPromptClick?: () => void; + onMcpResourcesClick?: () => void; +} + +export interface UseAttachmentMenuReturn { + readonly callbacks: Record void>; + isItemEnabled(enabledWhen: string | undefined): boolean; + isItemVisible(visibleWhen: string | undefined): boolean; + getSystemMessageTooltip(): string; +} + +/** + * useAttachmentMenu - Shared logic for attachment menu components. + * + * Encapsulates the modality-flag checks and callback wrapping that is + * identical across the desktop dropdown (`ChatFormActionAddDropdown`) + * and the mobile sheet (`ChatFormActionAddSheet`). + * + * @param getFlags - Getter returning the current modality capability flags. + * @param getCallbacks - Getter returning the raw action callbacks from props. + * @param close - Function that dismisses the hosting UI element (dropdown / sheet). + */ +export function useAttachmentMenu( + getFlags: () => AttachmentModalityFlags, + getCallbacks: () => AttachmentActionCallbacks, + close: () => void +): UseAttachmentMenuReturn { + const modalityFlags = $derived(getFlags()); + + const callbacks = $derived.by(() => { + const cbs = getCallbacks(); + const wrap = (fn?: () => void) => () => { + close(); + fn?.(); + }; + return { + [AttachmentAction.FILE_UPLOAD]: wrap(cbs.onFileUpload), + [AttachmentAction.SYSTEM_PROMPT_CLICK]: wrap(cbs.onSystemPromptClick), + [AttachmentAction.MCP_PROMPT_CLICK]: wrap(cbs.onMcpPromptClick), + [AttachmentAction.MCP_RESOURCES_CLICK]: wrap(cbs.onMcpResourcesClick) + }; + }); + + function isItemEnabled(enabledWhen: string | undefined): boolean { + if (!enabledWhen || enabledWhen === 'always') return true; + return !!modalityFlags[enabledWhen as keyof AttachmentModalityFlags]; + } + + function isItemVisible(visibleWhen: string | undefined): boolean { + if (!visibleWhen) return true; + return !!modalityFlags[visibleWhen as keyof AttachmentModalityFlags]; + } + + function getSystemMessageTooltip(): string { + return !page.params.id + ? 'Add custom system message for a new conversation' + : 'Inject custom system message at the beginning of the conversation'; + } + + return { + get callbacks() { + return callbacks; + }, + isItemEnabled, + isItemVisible, + getSystemMessageTooltip + }; +} diff --git a/tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts b/tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts new file mode 100644 index 000000000..f59e3ed4b --- /dev/null +++ b/tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts @@ -0,0 +1,206 @@ +import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants'; + +export interface AutoScrollOptions { + disabled?: boolean; +} + +/** + * Creates an auto-scroll controller for a scrollable container. + * + * Features: + * - Auto-scrolls to bottom during streaming/loading + * - Stops auto-scroll when user manually scrolls up + * - Resumes auto-scroll when user scrolls back to bottom + */ +export class AutoScrollController { + private _autoScrollEnabled = $state(true); + private _userScrolledUp = $state(false); + private _lastScrollTop = $state(0); + private _scrollInterval: ReturnType | undefined; + private _container: HTMLElement | undefined; + private _disabled: boolean; + private _mutationObserver: MutationObserver | null = null; + private _rafPending = false; + private _observerEnabled = false; + constructor(options: AutoScrollOptions = {}) { + this._disabled = options.disabled ?? false; + } + + get autoScrollEnabled(): boolean { + return this._autoScrollEnabled; + } + + get userScrolledUp(): boolean { + return this._userScrolledUp; + } + + /** + * Binds the controller to a scrollable container element. + */ + setContainer(container: HTMLElement | undefined): void { + this._doStopObserving(); + this._container = container; + + if (this._observerEnabled && container && !this._disabled) { + this._doStartObserving(); + } + } + + /** + * Updates the disabled state. + */ + setDisabled(disabled: boolean): void { + if (this._disabled === disabled) return; + this._disabled = disabled; + if (disabled) { + this._autoScrollEnabled = false; + this.stopInterval(); + this._doStopObserving(); + } else if (this._observerEnabled && this._container && !this._mutationObserver) { + this._doStartObserving(); + } + } + + /** + * Handles scroll events to detect user scroll direction and toggle auto-scroll. + */ + handleScroll(): void { + if (this._disabled || !this._container) return; + + const { scrollTop, scrollHeight, clientHeight } = this._container; + const distanceFromBottom = scrollHeight - clientHeight - scrollTop; + const isScrollingUp = scrollTop < this._lastScrollTop; + const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD; + + if (isScrollingUp && !isAtBottom) { + this._userScrolledUp = true; + this._autoScrollEnabled = false; + } else if (isAtBottom && this._userScrolledUp) { + this._userScrolledUp = false; + this._autoScrollEnabled = true; + } + + this._lastScrollTop = scrollTop; + } + + /** + * Scrolls the container to the bottom. + */ + scrollToBottom(behavior: ScrollBehavior = 'smooth'): void { + if (this._disabled || !this._container) return; + this._container.scrollTo({ top: this._container.scrollHeight, behavior }); + } + + /** + * Enables auto-scroll (e.g., when user sends a message). + */ + enable(): void { + if (this._disabled) return; + this._userScrolledUp = false; + this._autoScrollEnabled = true; + } + + /** + * Starts the auto-scroll interval for continuous scrolling during streaming. + */ + startInterval(): void { + if (this._disabled || this._scrollInterval) return; + + this._scrollInterval = setInterval(() => { + this.scrollToBottom(); + }, AUTO_SCROLL_INTERVAL); + } + + /** + * Stops the auto-scroll interval. + */ + stopInterval(): void { + if (this._scrollInterval) { + clearInterval(this._scrollInterval); + this._scrollInterval = undefined; + } + } + + /** + * Updates the auto-scroll interval based on streaming state. + * Call this in a $effect to automatically manage the interval. + */ + updateInterval(isStreaming: boolean): void { + if (this._disabled) { + this.stopInterval(); + return; + } + + if (isStreaming && this._autoScrollEnabled) { + if (!this._scrollInterval) { + this.startInterval(); + } + } else { + this.stopInterval(); + } + } + + /** + * Cleans up resources. Call this in onDestroy or when the component unmounts. + */ + destroy(): void { + this.stopInterval(); + this._doStopObserving(); + } + + /** + * Starts a MutationObserver on the container that auto-scrolls to bottom + * on content changes. More responsive than interval-based polling. + */ + startObserving(): void { + this._observerEnabled = true; + + if (this._container && !this._disabled && !this._mutationObserver) { + this._doStartObserving(); + } + } + + /** + * Stops the MutationObserver. + */ + stopObserving(): void { + this._observerEnabled = false; + this._doStopObserving(); + } + + private _doStartObserving(): void { + if (!this._container || this._mutationObserver) return; + + this._mutationObserver = new MutationObserver(() => { + if (!this._autoScrollEnabled || this._rafPending) return; + this._rafPending = true; + requestAnimationFrame(() => { + this._rafPending = false; + if (this._autoScrollEnabled && this._container) { + this._container.scrollTop = this._container.scrollHeight; + } + }); + }); + + this._mutationObserver.observe(this._container, { + childList: true, + subtree: true, + characterData: true + }); + } + + private _doStopObserving(): void { + if (this._mutationObserver) { + this._mutationObserver.disconnect(); + this._mutationObserver = null; + } + this._rafPending = false; + } +} + +/** + * Creates a new AutoScrollController instance. + */ +export function createAutoScrollController(options: AutoScrollOptions = {}): AutoScrollController { + return new AutoScrollController(options); +} diff --git a/tools/ui/src/lib/hooks/use-draft-messages.svelte.ts b/tools/ui/src/lib/hooks/use-draft-messages.svelte.ts new file mode 100644 index 000000000..11305b205 --- /dev/null +++ b/tools/ui/src/lib/hooks/use-draft-messages.svelte.ts @@ -0,0 +1,45 @@ +import { onMount } from 'svelte'; +import { afterNavigate, beforeNavigate } from '$app/navigation'; +import { draftMessagesStore } from '$lib/stores/draft-messages.svelte'; + +interface UseDraftMessagesOptions { + getChatId: () => string | undefined; + getMessage: () => string; + getFiles: () => ChatUploadedFile[]; + setMessage: (message: string) => void; + setFiles: (files: ChatUploadedFile[]) => void; + getInitialMessage: () => string; +} + +export function useDraftMessages(options: UseDraftMessagesOptions) { + onMount(() => { + const chatId = options.getChatId(); + const draft = draftMessagesStore.getDraftMessage(chatId); + + if ((draft.message || draft.files.length > 0) && !options.getInitialMessage()) { + options.setMessage(draft.message); + options.setFiles(draft.files); + } + }); + + beforeNavigate(() => { + const chatId = options.getChatId(); + draftMessagesStore.saveDraftMessage(chatId, options.getMessage(), options.getFiles()); + }); + + afterNavigate((navigation) => { + if (navigation?.from != null) { + const chatId = options.getChatId(); + const draft = draftMessagesStore.getDraftMessage(chatId); + options.setMessage(draft.message); + options.setFiles(draft.files); + } + }); + + function clearDraft() { + const chatId = options.getChatId(); + draftMessagesStore.clearDraftMessage(chatId); + } + + return { clearDraft }; +} diff --git a/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts b/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts new file mode 100644 index 000000000..05966a1a1 --- /dev/null +++ b/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts @@ -0,0 +1,60 @@ +import { goto } from '$app/navigation'; +import { KeyboardKey } from '$lib/enums'; +import { ROUTES } from '$lib/constants/routes'; + +interface KeyboardShortcutsCallbacks { + activateSearchMode?: () => void; + editActiveConversation?: () => void; + onSearchActivated?: () => void; + deleteActiveConversation?: () => void; + navigateToPrevConversation?: () => void; + navigateToNextConversation?: () => void; +} + +export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) { + function handleKeydown(event: KeyboardEvent) { + const isCmdOrCtrl = event.metaKey || event.ctrlKey; + + if (isCmdOrCtrl && event.key === KeyboardKey.K_LOWER) { + event.preventDefault(); + callbacks.activateSearchMode?.(); + callbacks.onSearchActivated?.(); + } + + if ( + isCmdOrCtrl && + event.shiftKey && + (event.key === KeyboardKey.O_LOWER || event.key === KeyboardKey.O_UPPER) + ) { + event.preventDefault(); + + goto(ROUTES.NEW_CHAT); + } + + if (event.shiftKey && isCmdOrCtrl && event.key === KeyboardKey.E_UPPER) { + event.preventDefault(); + callbacks.editActiveConversation?.(); + } + + if ( + isCmdOrCtrl && + event.shiftKey && + (event.key === KeyboardKey.D_LOWER || event.key === KeyboardKey.D_UPPER) + ) { + event.preventDefault(); + callbacks.deleteActiveConversation?.(); + } + + if (isCmdOrCtrl && event.shiftKey && event.key === KeyboardKey.ARROW_UP) { + event.preventDefault(); + callbacks.navigateToPrevConversation?.(); + } + + if (isCmdOrCtrl && event.shiftKey && event.key === KeyboardKey.ARROW_DOWN) { + event.preventDefault(); + callbacks.navigateToNextConversation?.(); + } + } + + return { handleKeydown }; +} diff --git a/tools/ui/src/lib/hooks/use-message-edit-context.svelte.ts b/tools/ui/src/lib/hooks/use-message-edit-context.svelte.ts new file mode 100644 index 000000000..71d1b66f8 --- /dev/null +++ b/tools/ui/src/lib/hooks/use-message-edit-context.svelte.ts @@ -0,0 +1,99 @@ +import { setMessageEditContext } from '$lib/contexts'; +import { MessageRole } from '$lib/enums'; +import { parseFilesToMessageExtras } from '$lib/utils/convert-files-to-extra'; + +interface UseMessageEditContextOptions { + getContent: () => string; + getExtras: () => DatabaseMessageExtra[]; + showSaveOnlyOption?: boolean; + onSave: (content: string, extras?: DatabaseMessageExtra[]) => void; +} + +export function useMessageEditContext(options: UseMessageEditContextOptions) { + let isEditing = $state(false); + let editedContent = $state(''); + let editedExtras = $state([]); + let editedUploadedFiles = $state([]); + + function handleEdit() { + editedContent = options.getContent(); + editedExtras = [...options.getExtras()]; + editedUploadedFiles = []; + isEditing = true; + } + + async function handleSaveEdit() { + const trimmed = editedContent.trim(); + if (!trimmed && editedExtras.length === 0 && editedUploadedFiles.length === 0) return; + + let finalExtras: DatabaseMessageExtra[] = $state.snapshot(editedExtras); + if (editedUploadedFiles.length > 0) { + const plainFiles = $state.snapshot(editedUploadedFiles); + const result = await parseFilesToMessageExtras(plainFiles); + const newExtras = result?.extras || []; + finalExtras = [...finalExtras, ...newExtras]; + } + + options.onSave(trimmed, finalExtras.length > 0 ? finalExtras : undefined); + isEditing = false; + } + + function handleCancelEdit() { + isEditing = false; + } + + setMessageEditContext({ + get isEditing() { + return isEditing; + }, + get editedContent() { + return editedContent; + }, + get editedExtras() { + return editedExtras; + }, + get editedUploadedFiles() { + return editedUploadedFiles; + }, + get originalContent() { + return options.getContent(); + }, + get originalExtras() { + return options.getExtras(); + }, + get showSaveOnlyOption() { + return options.showSaveOnlyOption ?? false; + }, + get showBranchAfterEditOption() { + return false; + }, + get shouldBranchAfterEdit() { + return false; + }, + get messageRole() { + return MessageRole.USER; + }, + setContent: (c: string) => { + editedContent = c; + }, + setExtras: (e: DatabaseMessageExtra[]) => { + editedExtras = e; + }, + setUploadedFiles: (f: ChatUploadedFile[]) => { + editedUploadedFiles = f; + }, + save: handleSaveEdit, + saveOnly: handleSaveEdit, + cancel: handleCancelEdit, + startEdit: handleEdit + }); + + return { + get isEditing() { + return isEditing; + }, + handleEdit, + handleSaveEdit, + handleCancelEdit + }; +} diff --git a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts new file mode 100644 index 000000000..537a2af18 --- /dev/null +++ b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts @@ -0,0 +1,253 @@ +import { onMount } from 'svelte'; +import { + modelsStore, + modelOptions, + modelsLoading, + modelsUpdating, + selectedModelId, + singleModelName +} from '$lib/stores/models.svelte'; +import { isRouterMode } from '$lib/stores/server.svelte'; +import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils'; +import type { ModelOption } from '$lib/types/models'; + +export interface UseModelsSelectorOptions { + currentModel: () => string | null; + useGlobalSelection?: () => boolean; + onModelChange?: () => + | ((modelId: string, modelName: string) => Promise | boolean | void) + | undefined; + onOpenChange?: (open: boolean) => void; +} + +export interface UseModelsSelectorReturn { + readonly options: ModelOption[]; + readonly loading: boolean; + readonly updating: boolean; + readonly activeId: string | null; + readonly isRouter: boolean; + readonly serverModel: string | null; + readonly isHighlightedCurrentModelActive: boolean; + readonly isCurrentModelInCache: boolean; + readonly filteredOptions: ModelOption[]; + readonly groupedFilteredOptions: ReturnType; + readonly isLoadingModel: boolean; + readonly searchTerm: string; + readonly showModelDialog: boolean; + readonly infoModelId: string | null; + setSearchTerm(value: string): void; + setShowModelDialog(value: boolean): void; + handleInfoClick(modelName: string): void; + handleSelect(modelId: string): Promise; + handleOpenChange(open: boolean): void; + isFavorite(model: string): boolean; + getDisplayOption(): ModelOption | undefined; +} + +/** + * Shared reactive state and logic for model selection. + * + * Used by both the desktop dropdown (`ModelsSelectorDropdown`) + * and the mobile sheet (`ModelsSelectorSheet`) to avoid + * duplicating store derivations, selection handling, and model loading. + */ +export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn { + const options = $derived( + modelOptions().filter((option) => { + const modelProps = modelsStore.getModelProps(option.model); + + return modelProps?.ui !== false; + }) + ); + const loading = $derived(modelsLoading()); + const updating = $derived(modelsUpdating()); + const activeId = $derived(selectedModelId()); + const isRouter = $derived(isRouterMode()); + const serverModel = $derived(singleModelName()); + + const currentModel = $derived(opts.currentModel()); + const useGlobalSelection = $derived(opts.useGlobalSelection?.() ?? false); + const onModelChange = $derived(opts.onModelChange?.()); + + const isHighlightedCurrentModelActive = $derived.by(() => { + if (!isRouter || !currentModel) return false; + const currentOption = options.find((option) => option.model === currentModel); + return currentOption ? currentOption.id === activeId : false; + }); + + const isCurrentModelInCache = $derived.by(() => { + if (!isRouter || !currentModel) return true; + return options.some((option) => option.model === currentModel); + }); + + let isLoadingModel = $state(false); + let searchTerm = $state(''); + let showModelDialog = $state(false); + let infoModelId = $state(null); + const filteredOptions = $derived(filterModelOptions(options, searchTerm)); + const groupedFilteredOptions = $derived( + groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) => + modelsStore.isModelLoaded(m) + ) + ); + + function handleInfoClick(modelName: string) { + infoModelId = modelName; + showModelDialog = true; + } + + onMount(() => { + modelsStore.fetch().catch((error) => { + console.error('Unable to load models:', error); + }); + }); + + function handleOpenChange(open: boolean) { + if (loading || updating) return; + + if (isRouter) { + searchTerm = ''; + + if (open) { + modelsStore.fetchRouterModels().then(() => { + modelsStore.fetchModalitiesForLoadedModels(); + }); + } + + opts.onOpenChange?.(open); + } else { + showModelDialog = open; + } + } + + async function handleSelect(modelId: string) { + const option = options.find((opt) => opt.id === modelId); + if (!option) return; + + let shouldCloseMenu = true; + + if (onModelChange) { + const result = await onModelChange(option.id, option.model); + if (result === false) { + shouldCloseMenu = false; + } + } else { + await modelsStore.selectModelById(option.id); + } + + if (shouldCloseMenu) { + handleOpenChange(false); + + requestAnimationFrame(() => { + const textarea = document.querySelector( + '[data-slot="chat-form"] textarea' + ); + textarea?.focus(); + }); + } + + if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) { + isLoadingModel = true; + modelsStore + .loadModel(option.model) + .catch((error) => console.error('Failed to load model:', error)) + .finally(() => (isLoadingModel = false)); + } + } + + function getDisplayOption(): ModelOption | undefined { + if (!isRouter) { + const displayModel = serverModel || currentModel; + if (displayModel) { + return { + id: serverModel ? 'current' : 'offline-current', + model: displayModel, + name: displayModel.split('/').pop() || displayModel, + capabilities: [] + }; + } + return undefined; + } + + if (useGlobalSelection && activeId) { + const selected = options.find((option) => option.id === activeId); + if (selected) return selected; + } + + if (currentModel) { + if (!isCurrentModelInCache) { + return { + id: 'not-in-cache', + model: currentModel, + name: currentModel.split('/').pop() || currentModel, + capabilities: [] + }; + } + return options.find((option) => option.model === currentModel); + } + + if (activeId) { + return options.find((option) => option.id === activeId); + } + + return undefined; + } + + return { + get options() { + return options; + }, + get loading() { + return loading; + }, + get updating() { + return updating; + }, + get activeId() { + return activeId; + }, + get isRouter() { + return isRouter; + }, + get serverModel() { + return serverModel; + }, + get isHighlightedCurrentModelActive() { + return isHighlightedCurrentModelActive; + }, + get isCurrentModelInCache() { + return isCurrentModelInCache; + }, + get filteredOptions() { + return filteredOptions; + }, + get groupedFilteredOptions() { + return groupedFilteredOptions; + }, + get isLoadingModel() { + return isLoadingModel; + }, + get searchTerm() { + return searchTerm; + }, + get showModelDialog() { + return showModelDialog; + }, + get infoModelId() { + return infoModelId; + }, + setSearchTerm(value: string) { + searchTerm = value; + }, + setShowModelDialog(value: boolean) { + showModelDialog = value; + }, + handleInfoClick, + handleSelect, + handleOpenChange, + isFavorite(model: string) { + return modelsStore.favoriteModelIds.has(model); + }, + getDisplayOption + }; +} diff --git a/tools/ui/src/lib/hooks/use-processing-state.svelte.ts b/tools/ui/src/lib/hooks/use-processing-state.svelte.ts new file mode 100644 index 000000000..f28031972 --- /dev/null +++ b/tools/ui/src/lib/hooks/use-processing-state.svelte.ts @@ -0,0 +1,325 @@ +import { activeProcessingState } from '$lib/stores/chat.svelte'; +import { config } from '$lib/stores/settings.svelte'; +import { STATS_UNITS } from '$lib/constants'; +import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } from '$lib/types'; + +export interface UseProcessingStateReturn { + readonly processingState: ApiProcessingState | null; + getProcessingDetails(): string[]; + getTechnicalDetails(): string[]; + getProcessingMessage(): string; + getPromptProgressText(): string | null; + getLiveProcessingStats(): LiveProcessingStats | null; + getLiveGenerationStats(): LiveGenerationStats | null; + shouldShowDetails(): boolean; + startMonitoring(): void; + stopMonitoring(): void; +} + +/** + * useProcessingState - Reactive processing state hook + * + * This hook provides reactive access to the processing state of the server. + * It directly reads from chatStore's reactive state and provides + * formatted processing details for UI display. + * + * **Features:** + * - Real-time processing state via direct reactive state binding + * - Context and output token tracking + * - Tokens per second calculation + * - Automatic updates when streaming data arrives + * - Supports multiple concurrent conversations + * + * @returns Hook interface with processing state and control methods + */ +export function useProcessingState(): UseProcessingStateReturn { + let isMonitoring = $state(false); + let lastKnownState = $state(null); + let lastKnownProcessingStats = $state(null); + + // Derive processing state reactively from chatStore's direct state + const processingState = $derived.by(() => { + if (!isMonitoring) { + return lastKnownState; + } + // Read directly from the reactive state export + return activeProcessingState(); + }); + + // Track last known state for keepStatsVisible functionality + $effect(() => { + if (processingState && isMonitoring) { + lastKnownState = processingState; + } + }); + + // Track last known processing stats for when promptProgress disappears + $effect(() => { + if (processingState?.promptProgress) { + const { processed, total, time_ms, cache } = processingState.promptProgress; + const actualProcessed = processed - cache; + const actualTotal = total - cache; + + if (actualProcessed > 0 && time_ms > 0) { + const tokensPerSecond = actualProcessed / (time_ms / 1000); + lastKnownProcessingStats = { + tokensProcessed: actualProcessed, + totalTokens: actualTotal, + timeMs: time_ms, + tokensPerSecond + }; + } + } + }); + + function getETASecs(done: number, total: number, elapsedMs: number): number | undefined { + const elapsedSecs = elapsedMs / 1000; + const progressETASecs = + done === 0 || elapsedSecs < 0.5 + ? undefined // can be the case for the 0% progress report + : elapsedSecs * (total / done - 1); + return progressETASecs; + } + + function startMonitoring(): void { + if (isMonitoring) return; + isMonitoring = true; + } + + function stopMonitoring(): void { + if (!isMonitoring) return; + isMonitoring = false; + + // Only clear last known state if keepStatsVisible is disabled + const currentConfig = config(); + if (!currentConfig.keepStatsVisible) { + lastKnownState = null; + lastKnownProcessingStats = null; + } + } + + function getProcessingMessage(): string { + if (!processingState) { + return 'Processing...'; + } + + switch (processingState.status) { + case 'initializing': + return 'Initializing...'; + case 'preparing': + if (processingState.progressPercent !== undefined) { + return `Processing (${processingState.progressPercent}%)`; + } + return 'Preparing response...'; + case 'generating': + return ''; + default: + return 'Processing...'; + } + } + + function getProcessingDetails(): string[] { + // Use current processing state or fall back to last known state + const stateToUse = processingState || lastKnownState; + if (!stateToUse) { + return []; + } + + const details: string[] = []; + + // Show prompt processing progress with ETA during preparation phase + if (stateToUse.promptProgress) { + const { processed, total, time_ms, cache } = stateToUse.promptProgress; + const actualProcessed = processed - cache; + const actualTotal = total - cache; + + if (actualProcessed < actualTotal && actualProcessed > 0) { + const percent = Math.round((actualProcessed / actualTotal) * 100); + const eta = getETASecs(actualProcessed, actualTotal, time_ms); + + if (eta !== undefined) { + const etaSecs = Math.ceil(eta); + details.push(`Processing ${percent}% (ETA: ${etaSecs}s)`); + } else { + details.push(`Processing ${percent}%`); + } + } + } + + // Always show context info when we have valid data + if ( + typeof stateToUse.contextTotal === 'number' && + stateToUse.contextUsed >= 0 && + stateToUse.contextTotal > 0 + ) { + const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100); + + details.push( + `Context: ${stateToUse.contextUsed}/${stateToUse.contextTotal} (${contextPercent}%)` + ); + } + + if (stateToUse.outputTokensUsed > 0) { + // Handle infinite max_tokens (-1) case + if (stateToUse.outputTokensMax <= 0) { + details.push(`Output: ${stateToUse.outputTokensUsed}/∞`); + } else { + const outputPercent = Math.round( + (stateToUse.outputTokensUsed / stateToUse.outputTokensMax) * 100 + ); + + details.push( + `Output: ${stateToUse.outputTokensUsed}/${stateToUse.outputTokensMax} (${outputPercent}%)` + ); + } + } + + if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) { + details.push(`${stateToUse.tokensPerSecond.toFixed(1)} ${STATS_UNITS.TOKENS_PER_SECOND}`); + } + + if (stateToUse.speculative) { + details.push('Speculative decoding enabled'); + } + + return details; + } + + /** + * Returns technical details without the progress message (for bottom bar) + */ + function getTechnicalDetails(): string[] { + const stateToUse = processingState || lastKnownState; + if (!stateToUse) { + return []; + } + + const details: string[] = []; + + // Always show context info when we have valid data + if ( + typeof stateToUse.contextTotal === 'number' && + stateToUse.contextUsed >= 0 && + stateToUse.contextTotal > 0 + ) { + const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100); + + details.push( + `Context: ${stateToUse.contextUsed}/${stateToUse.contextTotal} (${contextPercent}%)` + ); + } + + if (stateToUse.outputTokensUsed > 0) { + // Handle infinite max_tokens (-1) case + if (stateToUse.outputTokensMax <= 0) { + details.push(`Output: ${stateToUse.outputTokensUsed}/∞`); + } else { + const outputPercent = Math.round( + (stateToUse.outputTokensUsed / stateToUse.outputTokensMax) * 100 + ); + + details.push( + `Output: ${stateToUse.outputTokensUsed}/${stateToUse.outputTokensMax} (${outputPercent}%)` + ); + } + } + + if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) { + details.push(`${stateToUse.tokensPerSecond.toFixed(1)} ${STATS_UNITS.TOKENS_PER_SECOND}`); + } + + if (stateToUse.speculative) { + details.push('Speculative decoding enabled'); + } + + return details; + } + + function shouldShowDetails(): boolean { + return processingState !== null && processingState.status !== 'idle'; + } + + /** + * Returns a short progress message with percent + */ + function getPromptProgressText(): string | null { + if (!processingState?.promptProgress) return null; + + const { processed, total, cache } = processingState.promptProgress; + + const actualProcessed = processed - cache; + const actualTotal = total - cache; + const percent = Math.round((actualProcessed / actualTotal) * 100); + const eta = getETASecs(actualProcessed, actualTotal, processingState.promptProgress.time_ms); + + if (eta !== undefined) { + const etaSecs = Math.ceil(eta); + return `Processing ${percent}% (ETA: ${etaSecs}s)`; + } + + return `Processing ${percent}%`; + } + + /** + * Returns live processing statistics for display (prompt processing phase) + * Returns last known stats when promptProgress becomes unavailable + */ + function getLiveProcessingStats(): LiveProcessingStats | null { + if (processingState?.promptProgress) { + const { processed, total, time_ms, cache } = processingState.promptProgress; + + const actualProcessed = processed - cache; + const actualTotal = total - cache; + + if (actualProcessed > 0 && time_ms > 0) { + const tokensPerSecond = actualProcessed / (time_ms / 1000); + + return { + tokensProcessed: actualProcessed, + totalTokens: actualTotal, + timeMs: time_ms, + tokensPerSecond + }; + } + } + + // Return last known stats if promptProgress is no longer available + return lastKnownProcessingStats; + } + + /** + * Returns live generation statistics for display (token generation phase) + */ + function getLiveGenerationStats(): LiveGenerationStats | null { + if (!processingState) return null; + + const { tokensDecoded, tokensPerSecond } = processingState; + + if (tokensDecoded <= 0) return null; + + // Calculate time from tokens and speed + const timeMs = + tokensPerSecond && tokensPerSecond > 0 ? (tokensDecoded / tokensPerSecond) * 1000 : 0; + + return { + tokensGenerated: tokensDecoded, + timeMs, + tokensPerSecond: tokensPerSecond || 0 + }; + } + + return { + get processingState() { + return processingState; + }, + getProcessingDetails, + getTechnicalDetails, + getProcessingMessage, + getPromptProgressText, + getLiveProcessingStats, + getLiveGenerationStats, + shouldShowDetails, + startMonitoring, + stopMonitoring + }; +} diff --git a/tools/ui/src/lib/hooks/use-scroll-carousel.svelte.ts b/tools/ui/src/lib/hooks/use-scroll-carousel.svelte.ts new file mode 100644 index 000000000..e4c75d236 --- /dev/null +++ b/tools/ui/src/lib/hooks/use-scroll-carousel.svelte.ts @@ -0,0 +1,61 @@ +export function useScrollCarousel() { + let canScrollLeft = $state(false); + let canScrollRight = $state(false); + let scrollContainer = $state(); + + function scrollToCenter(element: HTMLElement) { + if (!scrollContainer) return; + + const containerRect = scrollContainer.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + const elementCenter = elementRect.left + elementRect.width / 2; + const containerCenter = containerRect.left + containerRect.width / 2; + const scrollOffset = elementCenter - containerCenter; + + scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' }); + } + + function scrollLeft() { + if (!scrollContainer) return; + scrollContainer.scrollBy({ left: -250, behavior: 'smooth' }); + } + + function scrollRight() { + if (!scrollContainer) return; + scrollContainer.scrollBy({ left: 250, behavior: 'smooth' }); + } + + function updateScrollButtons() { + if (!scrollContainer) return; + + const { scrollLeft: sl, scrollWidth, clientWidth } = scrollContainer; + canScrollLeft = sl > 0; + canScrollRight = sl < scrollWidth - clientWidth - 1; + } + + $effect(() => { + if (scrollContainer) { + updateScrollButtons(); + } + }); + + return { + get canScrollLeft() { + return canScrollLeft; + }, + get canScrollRight() { + return canScrollRight; + }, + get scrollContainer() { + return scrollContainer; + }, + set scrollContainer(el: HTMLDivElement | undefined) { + scrollContainer = el; + }, + scrollToCenter, + scrollLeft, + scrollRight, + updateScrollButtons + }; +} diff --git a/tools/ui/src/lib/hooks/use-settings-navigation.svelte.ts b/tools/ui/src/lib/hooks/use-settings-navigation.svelte.ts new file mode 100644 index 000000000..3cbcaaeda --- /dev/null +++ b/tools/ui/src/lib/hooks/use-settings-navigation.svelte.ts @@ -0,0 +1,46 @@ +import { page } from '$app/state'; +import { beforeNavigate } from '$app/navigation'; +import { settingsReferrer } from '$lib/stores/settings-referrer.svelte'; +import { ROUTES } from '$lib/constants/routes'; + +export interface ChatSettings { + reset: () => void; +} + +export function useSettingsNavigation() { + const subroute = $state({ + activePanel: 'chat' as 'chat' | 'settings' | 'mcp', + chatSettingsRef: undefined as ChatSettings | undefined + }); + + const isSettingsRoute = $derived(!!page.route.id?.startsWith('/settings')); + + beforeNavigate(({ to, from }) => { + if (to?.route?.id?.startsWith('/settings') && !from?.route?.id?.startsWith('/settings')) { + settingsReferrer.url = window.location.hash || ROUTES.START; + } + }); + + $effect(() => { + if (subroute.activePanel === 'settings' && subroute.chatSettingsRef) { + subroute.chatSettingsRef.reset(); + } + }); + + // Return to chat when navigating to a new route + $effect(() => { + void page.url; + + subroute.activePanel = 'chat'; + }); + + return { + get panel() { + return subroute; + }, + + get isSettingsRoute() { + return isSettingsRoute; + } + }; +} diff --git a/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts b/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts new file mode 100644 index 000000000..911af322a --- /dev/null +++ b/tools/ui/src/lib/hooks/use-tools-panel.svelte.ts @@ -0,0 +1,122 @@ +import { CLI_FLAGS } from '$lib/constants'; +import { SvelteSet } from 'svelte/reactivity'; +import { ToolSource } from '$lib/enums'; +import { conversationsStore } from '$lib/stores/conversations.svelte'; +import { mcpStore } from '$lib/stores/mcp.svelte'; +import { toolsStore } from '$lib/stores/tools.svelte'; +import type { ToolGroup } from '$lib/types'; + +export interface UseToolsPanelReturn { + readonly expandedGroups: SvelteSet; + readonly groups: ToolGroup[]; + readonly activeGroups: ToolGroup[]; + readonly totalToolCount: number; + readonly noToolsInfoMessage: string | null; + getGroupCheckedState(group: ToolGroup): { checked: boolean; indeterminate: boolean }; + getEnabledToolCount(group: ToolGroup): number; + getFavicon(group: { source: ToolSource; label: string }): string | null; + isGroupDisabled(group: ToolGroup): boolean; + toggleGroupExpanded(label: string): void; + handleOpen(): void; +} + +/** + * Shared reactive state and helpers for the tools panel UI. + * + * Used by both the desktop dropdown (`ChatFormActionAddToolsSubmenu`) + * and the mobile sheet (`ChatFormActionAddSheet`) to avoid + * duplicating group filtering, checked-state derivation, and favicon logic. + */ +export function useToolsPanel(): UseToolsPanelReturn { + const expandedGroups = new SvelteSet(); + + const groups = $derived(toolsStore.toolGroups); + const activeGroups = $derived( + groups.filter( + (g) => + g.source !== ToolSource.MCP || + !g.serverId || + conversationsStore.isMcpServerEnabledForChat(g.serverId) + ) + ); + const totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0)); + const noToolsInfoMessage = $derived.by(() => { + if (toolsStore.loading) return null; + if (toolsStore.toolGroups.length > 0) return null; + // Tools endpoint is unreachable (404) — server started without --tools + if (toolsStore.isToolsEndpointUnreachable) { + return `To enable Built-In Tools you need to run llama-server with ${CLI_FLAGS.TOOLS} all or ${CLI_FLAGS.TOOLS} flag. To see MCP Tools you need to add / enable MCP Server(s).`; + } + // Other errors — return null so UI shows "Failed to load tools" + if (toolsStore.error) return null; + return `To enable Built-In Tools you need to run llama-server with ${CLI_FLAGS.TOOLS} all or ${CLI_FLAGS.TOOLS} flag. To see MCP Tools you need to add / enable MCP Server(s).`; + }); + + function getGroupCheckedState(group: ToolGroup): { checked: boolean; indeterminate: boolean } { + return { + checked: toolsStore.isGroupFullyEnabled(group), + indeterminate: toolsStore.isGroupPartiallyEnabled(group) + }; + } + + function getEnabledToolCount(group: ToolGroup): number { + return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.function.name)).length; + } + + function getFavicon(group: { source: ToolSource; label: string }): string | null { + if (group.source !== ToolSource.MCP) return null; + + for (const server of mcpStore.getServersSorted()) { + if (mcpStore.getServerLabel(server) === group.label) { + return mcpStore.getServerFavicon(server.id); + } + } + + return null; + } + + function isGroupDisabled(group: ToolGroup): boolean { + return ( + group.source === ToolSource.MCP && + !!group.serverId && + !conversationsStore.isMcpServerEnabledForChat(group.serverId) + ); + } + + function toggleGroupExpanded(label: string): void { + if (expandedGroups.has(label)) { + expandedGroups.delete(label); + } else { + expandedGroups.add(label); + } + } + + function handleOpen(): void { + if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) { + toolsStore.fetchBuiltinTools(); + } + mcpStore.runHealthChecksForServers(mcpStore.getServersSorted().filter((s) => s.enabled)); + } + + return { + expandedGroups, + get groups() { + return groups; + }, + get activeGroups() { + return activeGroups; + }, + get totalToolCount() { + return totalToolCount; + }, + get noToolsInfoMessage() { + return noToolsInfoMessage; + }, + getGroupCheckedState, + getEnabledToolCount, + getFavicon, + isGroupDisabled, + toggleGroupExpanded, + handleOpen + }; +} diff --git a/tools/ui/src/lib/services/chat.service.ts b/tools/ui/src/lib/services/chat.service.ts new file mode 100644 index 000000000..08649abd2 --- /dev/null +++ b/tools/ui/src/lib/services/chat.service.ts @@ -0,0 +1,1094 @@ +import { getJsonHeaders } from '$lib/utils/api-headers'; +import { formatAttachmentText } from '$lib/utils/formatters'; +import { isAbortError } from '$lib/utils/abort'; +import { + ATTACHMENT_LABEL_PDF_FILE, + ATTACHMENT_LABEL_MCP_PROMPT, + ATTACHMENT_LABEL_MCP_RESOURCE, + LEGACY_AGENTIC_REGEX +} from '$lib/constants'; +import { + AttachmentType, + ContentPartType, + MessageRole, + ReasoningFormat, + UrlProtocol +} from '$lib/enums'; +import type { + ApiChatMessageContentPart, + ApiChatMessageData, + ApiChatCompletionToolCall +} from '$lib/types/api'; +import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } from '$lib/types'; +import { modelsStore } from '$lib/stores/models.svelte'; + +export class ChatService { + /** + * + * + * Title Generation + * + * + */ + + /** + * Sends a streaming chat completion request for generating a chat title. + * Delegates to `sendMessage` for fetch, SSE parsing, and error handling. + * + * @param message - The single message to send (a user message containing the title generation prompt) + * @param model - Optional model name to use (required in ROUTER mode) + * @param signal - Optional AbortSignal to cancel the request + * @returns {Promise} The aggregated title text, or empty string if request failed + * @static + */ + static async generateTitle( + message: ApiChatMessageData, + model?: string | null, + signal?: AbortSignal + ): Promise { + let titleResponse = ''; + try { + await ChatService.sendMessage( + [message], + { + model: model || undefined, + stream: true, + custom: { chat_template_kwargs: { enable_thinking: false } }, + onChunk: (chunk: string) => { + titleResponse += chunk; + } + }, + undefined, + signal + ); + } catch { + return ''; + } + return titleResponse; + } + + /** + * + * + * Messaging + * + * + */ + + /** + * Sends a chat completion request to the llama-server. + * Supports both streaming and non-streaming responses with comprehensive parameter configuration. + * Automatically converts database messages with attachments to the appropriate API format. + * + * @param messages - Array of chat messages to send to the API (supports both ApiChatMessageData and DatabaseMessage with attachments) + * @param options - Configuration options for the chat completion request. See `SettingsChatServiceOptions` type for details. + * @returns {Promise} that resolves to the complete response string (non-streaming) or void (streaming) + * @throws {Error} if the request fails or is aborted + */ + static async sendMessage( + messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[], + options: SettingsChatServiceOptions = {}, + conversationId?: string, + signal?: AbortSignal + ): Promise { + const { + stream, + onChunk, + onComplete, + onError, + onReasoningChunk, + onToolCallChunk, + onModel, + onTimings, + // Tools for function calling + tools, + // Generation parameters + temperature, + max_tokens, + // Sampling parameters + dynatemp_range, + dynatemp_exponent, + top_k, + top_p, + min_p, + xtc_probability, + xtc_threshold, + typ_p, + // Penalty parameters + repeat_last_n, + repeat_penalty, + presence_penalty, + frequency_penalty, + dry_multiplier, + dry_base, + dry_allowed_length, + dry_penalty_last_n, + // Other parameters + samplers, + backend_sampling, + custom, + timings_per_token, + // Config options + disableReasoningParsing, + excludeReasoningFromContext, + continueFinalMessage + } = options; + + const normalizedMessages: ApiChatMessageData[] = messages + .map((msg) => { + if ('id' in msg && 'convId' in msg && 'timestamp' in msg) { + const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }; + + return ChatService.convertDbMessageToApiChatMessageData(dbMsg); + } else { + return msg as ApiChatMessageData; + } + }) + .filter((msg) => { + // Filter out empty system messages + if (msg.role === MessageRole.SYSTEM) { + const content = typeof msg.content === 'string' ? msg.content : ''; + + return content.trim().length > 0; + } + + return true; + }); + + // Filter out image attachments if the model doesn't support vision + if (options.model && !modelsStore.modelSupportsVision(options.model)) { + normalizedMessages.forEach((msg) => { + if (Array.isArray(msg.content)) { + msg.content = msg.content.filter((part: ApiChatMessageContentPart) => { + if (part.type === ContentPartType.IMAGE_URL) { + console.info( + `[ChatService] Skipping image attachment in message history (model "${options.model}" does not support vision)` + ); + + return false; + } + + return true; + }); + // If only text remains and it's a single part, simplify to string + if ( + msg.content.length === 1 && + msg.content[0].type === ContentPartType.TEXT && + typeof msg.content[0].text === 'string' + ) { + msg.content = msg.content[0].text; + } + } + }); + } + + const requestBody: ApiChatCompletionRequest = { + messages: normalizedMessages.map((msg: ApiChatMessageData) => { + const mapped: ApiChatCompletionRequest['messages'][0] = { + role: msg.role, + content: msg.content, + tool_calls: msg.tool_calls, + tool_call_id: msg.tool_call_id + }; + // Include reasoning_content from the dedicated field + if (!excludeReasoningFromContext && msg.reasoning_content) { + mapped.reasoning_content = msg.reasoning_content; + } + return mapped; + }), + stream, + return_progress: stream ? true : undefined, + tools: tools && tools.length > 0 ? tools : undefined + }; + + // Include model in request if provided (required in ROUTER mode) + if (options.model) { + requestBody.model = options.model; + } + + requestBody.reasoning_format = disableReasoningParsing + ? ReasoningFormat.NONE + : ReasoningFormat.AUTO; + + if (continueFinalMessage) { + requestBody.continue_final_message = true; + requestBody.add_generation_prompt = false; + } + + if (temperature !== undefined) requestBody.temperature = temperature; + if (max_tokens !== undefined) { + // Set max_tokens to -1 (infinite) when explicitly configured as 0 or null + requestBody.max_tokens = max_tokens !== null && max_tokens !== 0 ? max_tokens : -1; + } + + if (dynatemp_range !== undefined) requestBody.dynatemp_range = dynatemp_range; + if (dynatemp_exponent !== undefined) requestBody.dynatemp_exponent = dynatemp_exponent; + if (top_k !== undefined) requestBody.top_k = top_k; + if (top_p !== undefined) requestBody.top_p = top_p; + if (min_p !== undefined) requestBody.min_p = min_p; + if (xtc_probability !== undefined) requestBody.xtc_probability = xtc_probability; + if (xtc_threshold !== undefined) requestBody.xtc_threshold = xtc_threshold; + if (typ_p !== undefined) requestBody.typ_p = typ_p; + + if (repeat_last_n !== undefined) requestBody.repeat_last_n = repeat_last_n; + if (repeat_penalty !== undefined) requestBody.repeat_penalty = repeat_penalty; + if (presence_penalty !== undefined) requestBody.presence_penalty = presence_penalty; + if (frequency_penalty !== undefined) requestBody.frequency_penalty = frequency_penalty; + if (dry_multiplier !== undefined) requestBody.dry_multiplier = dry_multiplier; + if (dry_base !== undefined) requestBody.dry_base = dry_base; + if (dry_allowed_length !== undefined) requestBody.dry_allowed_length = dry_allowed_length; + if (dry_penalty_last_n !== undefined) requestBody.dry_penalty_last_n = dry_penalty_last_n; + + if (samplers !== undefined) { + requestBody.samplers = + typeof samplers === 'string' + ? samplers.split(';').filter((s: string) => s.trim()) + : samplers; + } + + if (backend_sampling !== undefined) requestBody.backend_sampling = backend_sampling; + + if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token; + + if (custom) { + try { + const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom; + Object.assign(requestBody, customParams); + } catch (error) { + console.warn('Failed to parse custom parameters:', error); + } + } + + try { + const response = await fetch(`./v1/chat/completions`, { + method: 'POST', + headers: getJsonHeaders(), + body: JSON.stringify(requestBody), + signal + }); + + if (!response.ok) { + const error = await ChatService.parseErrorResponse(response); + + if (onError) { + onError(error); + } + + throw error; + } + + if (stream) { + await ChatService.handleStreamResponse( + response, + onChunk, + onComplete, + onError, + onReasoningChunk, + onToolCallChunk, + onModel, + onTimings, + conversationId, + signal + ); + + return; + } else { + return ChatService.handleNonStreamResponse( + response, + onComplete, + onError, + onToolCallChunk, + onModel + ); + } + } catch (error) { + if (isAbortError(error)) { + console.log('Chat completion request was aborted'); + return; + } + + let userFriendlyError: Error; + + if (error instanceof Error) { + if (error.name === 'TypeError' && error.message.includes('fetch')) { + userFriendlyError = new Error( + 'Unable to connect to server - please check if the server is running' + ); + userFriendlyError.name = 'NetworkError'; + } else if (error.message.includes('ECONNREFUSED')) { + userFriendlyError = new Error('Connection refused - server may be offline'); + userFriendlyError.name = 'NetworkError'; + } else if (error.message.includes('ETIMEDOUT')) { + userFriendlyError = new Error('Request timed out - the server took too long to respond'); + userFriendlyError.name = 'TimeoutError'; + } else { + userFriendlyError = error; + } + } else { + userFriendlyError = new Error('Unknown error occurred while sending message'); + } + + console.error('Error in sendMessage:', error); + + if (onError) { + onError(userFriendlyError); + } + + throw userFriendlyError; + } + } + + /** + * Checks whether all server slots are currently idle (not processing any requests). + * Queries the /slots endpoint (requires --slots flag on the server). + * Returns true if all slots are idle, false if any is processing. + * If the endpoint is unavailable or errors out, returns true (best-effort fallback). + * + * @param signal - Optional AbortSignal to cancel the request if needed + * @param model - Optional model name to check slots for (required in ROUTER mode) + * @returns {Promise} Promise that resolves to true if all slots are idle, false if any is processing + */ + static async areAllSlotsIdle(model?: string | null, signal?: AbortSignal): Promise { + try { + const url = model ? `./slots?model=${encodeURIComponent(model)}` : './slots'; + const res = await fetch(url, { signal }); + if (!res.ok) return true; + + const slots: { is_processing: boolean }[] = await res.json(); + return slots.every((s) => !s.is_processing); + } catch { + return true; + } + } + + /** + * Sends a fire-and-forget request to pre-encode the conversation in the server's KV cache. + * After a response completes, this re-submits the full conversation + * using n_predict=0 and stream=false so the server processes the prompt without generating tokens. + * This warms the cache for the next turn, making it faster. + * + * When excludeReasoningFromContext is true, reasoning content is stripped from the messages + * to match what sendMessage would send on the next turn (avoiding cache misses). + * When false, reasoning_content is preserved so the cached prompt matches the next request. + * + * @param messages - The full conversation including the latest assistant response + * @param model - Optional model name (required in ROUTER mode) + * @param excludeReasoning - Whether to strip reasoning content (should match excludeReasoningFromContext setting) + * @param signal - Optional AbortSignal to cancel the pre-encode request + */ + static async preEncode( + messages: ApiChatMessageData[] | (DatabaseMessage & { extra?: DatabaseMessageExtra[] })[], + model?: string | null, + excludeReasoning?: boolean, + signal?: AbortSignal + ): Promise { + const normalizedMessages: ApiChatMessageData[] = messages + .map((msg) => { + if ('id' in msg && 'convId' in msg && 'timestamp' in msg) { + return ChatService.convertDbMessageToApiChatMessageData( + msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] } + ); + } + + return msg as ApiChatMessageData; + }) + .filter((msg) => { + if (msg.role === MessageRole.SYSTEM) { + const content = typeof msg.content === 'string' ? msg.content : ''; + + return content.trim().length > 0; + } + + return true; + }); + + const requestBody: Record = { + messages: normalizedMessages.map((msg: ApiChatMessageData) => { + const mapped: Record = { + role: msg.role, + content: excludeReasoning ? ChatService.stripReasoningContent(msg.content) : msg.content, + tool_calls: msg.tool_calls, + tool_call_id: msg.tool_call_id + }; + + if (!excludeReasoning && msg.reasoning_content) { + mapped.reasoning_content = msg.reasoning_content; + } + + return mapped; + }), + stream: false, + n_predict: 0 + }; + + if (model) { + requestBody.model = model; + } + + try { + await fetch(`./v1/chat/completions`, { + method: 'POST', + headers: getJsonHeaders(), + body: JSON.stringify(requestBody), + signal + }); + } catch (error) { + if (!isAbortError(error)) { + console.warn('[ChatService] Pre-encode request failed:', error); + } + } + } + + /** + * + * + * Streaming + * + * + */ + + /** + * Handles streaming response from the chat completion API + * @param response - The Response object from the fetch request + * @param onChunk - Optional callback invoked for each content chunk received + * @param onComplete - Optional callback invoked when the stream is complete with full response + * @param onError - Optional callback invoked if an error occurs during streaming + * @param onReasoningChunk - Optional callback invoked for each reasoning content chunk + * @param conversationId - Optional conversation ID for per-conversation state tracking + * @returns {Promise} Promise that resolves when streaming is complete + * @throws {Error} if the stream cannot be read or parsed + */ + private static async handleStreamResponse( + response: Response, + onChunk?: (chunk: string) => void, + onComplete?: ( + response: string, + reasoningContent?: string, + timings?: ChatMessageTimings, + toolCalls?: string + ) => void, + onError?: (error: Error) => void, + onReasoningChunk?: (chunk: string) => void, + onToolCallChunk?: (chunk: string) => void, + onModel?: (model: string) => void, + onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void, + conversationId?: string, + abortSignal?: AbortSignal + ): Promise { + const reader = response.body?.getReader(); + + if (!reader) { + throw new Error('No response body'); + } + + const decoder = new TextDecoder(); + let aggregatedContent = ''; + let fullReasoningContent = ''; + let aggregatedToolCalls: ApiChatCompletionToolCall[] = []; + let lastTimings: ChatMessageTimings | undefined; + let streamFinished = false; + let modelEmitted = false; + let toolCallIndexOffset = 0; + let hasOpenToolCallBatch = false; + + const finalizeOpenToolCallBatch = () => { + if (!hasOpenToolCallBatch) { + return; + } + + toolCallIndexOffset = aggregatedToolCalls.length; + hasOpenToolCallBatch = false; + }; + + const processToolCallDelta = (toolCalls?: ApiChatCompletionToolCallDelta[]) => { + if (!toolCalls || toolCalls.length === 0) { + return; + } + + aggregatedToolCalls = ChatService.mergeToolCallDeltas( + aggregatedToolCalls, + toolCalls, + toolCallIndexOffset + ); + + if (aggregatedToolCalls.length === 0) { + return; + } + + hasOpenToolCallBatch = true; + + const serializedToolCalls = JSON.stringify(aggregatedToolCalls); + + if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { + console.log('[ChatService] Aggregated tool calls:', serializedToolCalls); + } + + if (!serializedToolCalls) { + return; + } + + if (!abortSignal?.aborted) { + onToolCallChunk?.(serializedToolCalls); + } + }; + + try { + let chunk = ''; + while (true) { + if (abortSignal?.aborted) break; + + const { done, value } = await reader.read(); + if (done) break; + + if (abortSignal?.aborted) break; + + chunk += decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + chunk = lines.pop() || ''; + + for (const line of lines) { + if (abortSignal?.aborted) break; + + if (line.startsWith(UrlProtocol.DATA)) { + const data = line.slice(6); + if (data === '[DONE]') { + streamFinished = true; + + continue; + } + + try { + const parsed: ApiChatCompletionStreamChunk = JSON.parse(data); + const choice = parsed.choices?.[0]; + const content = choice?.delta?.content; + const reasoningContent = choice?.delta?.reasoning_content; + const toolCalls = choice?.delta?.tool_calls; + const timings = parsed.timings; + const promptProgress = parsed.prompt_progress; + + const chunkModel = ChatService.extractModelName(parsed); + if (chunkModel && !modelEmitted) { + modelEmitted = true; + onModel?.(chunkModel); + } + + if (promptProgress) { + ChatService.notifyTimings(undefined, promptProgress, onTimings); + } + + if (timings) { + ChatService.notifyTimings(timings, promptProgress, onTimings); + lastTimings = timings; + } + + if (content) { + finalizeOpenToolCallBatch(); + aggregatedContent += content; + if (!abortSignal?.aborted) { + onChunk?.(content); + } + } + + if (reasoningContent) { + finalizeOpenToolCallBatch(); + fullReasoningContent += reasoningContent; + if (!abortSignal?.aborted) { + onReasoningChunk?.(reasoningContent); + } + } + + processToolCallDelta(toolCalls); + } catch (e) { + console.error('Error parsing JSON chunk:', e); + } + } + } + + if (abortSignal?.aborted) break; + } + + if (abortSignal?.aborted) return; + + if (streamFinished) { + finalizeOpenToolCallBatch(); + + const finalToolCalls = + aggregatedToolCalls.length > 0 ? JSON.stringify(aggregatedToolCalls) : undefined; + + onComplete?.( + aggregatedContent, + fullReasoningContent || undefined, + lastTimings, + finalToolCalls + ); + } + } catch (error) { + const err = error instanceof Error ? error : new Error('Stream error'); + + onError?.(err); + + throw err; + } finally { + reader.releaseLock(); + } + } + + /** + * Handles non-streaming response from the chat completion API. + * Parses the JSON response and extracts the generated content. + * + * @param response - The fetch Response object containing the JSON data + * @param onComplete - Optional callback invoked when response is successfully parsed + * @param onError - Optional callback invoked if an error occurs during parsing + * @returns {Promise} Promise that resolves to the generated content string + * @throws {Error} if the response cannot be parsed or is malformed + */ + private static async handleNonStreamResponse( + response: Response, + onComplete?: ( + response: string, + reasoningContent?: string, + timings?: ChatMessageTimings, + toolCalls?: string + ) => void, + onError?: (error: Error) => void, + onToolCallChunk?: (chunk: string) => void, + onModel?: (model: string) => void + ): Promise { + try { + const responseText = await response.text(); + + if (!responseText.trim()) { + const noResponseError = new Error('No response received from server. Please try again.'); + + throw noResponseError; + } + + const data: ApiChatCompletionResponse = JSON.parse(responseText); + + const responseModel = ChatService.extractModelName(data); + if (responseModel) { + onModel?.(responseModel); + } + + const content = data.choices[0]?.message?.content || ''; + const reasoningContent = data.choices[0]?.message?.reasoning_content; + const toolCalls = data.choices[0]?.message?.tool_calls; + + let serializedToolCalls: string | undefined; + + if (toolCalls && toolCalls.length > 0) { + const mergedToolCalls = ChatService.mergeToolCallDeltas([], toolCalls); + + if (mergedToolCalls.length > 0) { + serializedToolCalls = JSON.stringify(mergedToolCalls); + if (serializedToolCalls) { + onToolCallChunk?.(serializedToolCalls); + } + } + } + + if (!content.trim() && !serializedToolCalls) { + const noResponseError = new Error('No response received from server. Please try again.'); + + throw noResponseError; + } + + onComplete?.(content, reasoningContent, undefined, serializedToolCalls); + + return content; + } catch (error) { + const err = error instanceof Error ? error : new Error('Parse error'); + + onError?.(err); + + throw err; + } + } + + /** + * Merges tool call deltas into an existing array of tool calls. + * Handles both existing and new tool calls, updating existing ones and adding new ones. + * + * @param existing - The existing array of tool calls to merge into + * @param deltas - The array of tool call deltas to merge + * @param indexOffset - Optional offset to apply to the index of new tool calls + * @returns {ApiChatCompletionToolCall[]} The merged array of tool calls + */ + private static mergeToolCallDeltas( + existing: ApiChatCompletionToolCall[], + deltas: ApiChatCompletionToolCallDelta[], + indexOffset = 0 + ): ApiChatCompletionToolCall[] { + const result = existing.map((call) => ({ + ...call, + function: call.function ? { ...call.function } : undefined + })); + + for (const delta of deltas) { + const index = + typeof delta.index === 'number' && delta.index >= 0 + ? delta.index + indexOffset + : result.length; + + while (result.length <= index) { + result.push({ function: undefined }); + } + + const target = result[index]!; + + if (delta.id) { + target.id = delta.id; + } + + if (delta.type) { + target.type = delta.type; + } + + if (delta.function) { + const fn = target.function ? { ...target.function } : {}; + + if (delta.function.name) { + fn.name = delta.function.name; + } + + if (delta.function.arguments) { + fn.arguments = (fn.arguments ?? '') + delta.function.arguments; + } + + target.function = fn; + } + } + + return result; + } + + /** + * + * + * Conversion + * + * + */ + + /** + * Converts a database message with attachments to API chat message format. + * Processes various attachment types (images, text files, PDFs) and formats them + * as content parts suitable for the chat completion API. + * + * @param message - Database message object with optional extra attachments + * @param message.content - The text content of the message + * @param message.role - The role of the message sender (user, assistant, system) + * @param message.extra - Optional array of message attachments (images, files, etc.) + * @returns {ApiChatMessageData} object formatted for the chat completion API + * @static + */ + static convertDbMessageToApiChatMessageData( + message: DatabaseMessage & { extra?: DatabaseMessageExtra[] } + ): ApiChatMessageData { + // Handle tool result messages (role: 'tool') + if (message.role === MessageRole.TOOL && message.toolCallId) { + return { + role: MessageRole.TOOL, + content: message.content, + tool_call_id: message.toolCallId + }; + } + + // Parse tool calls for assistant messages + let toolCalls: ApiChatCompletionToolCall[] | undefined; + if (message.toolCalls) { + try { + toolCalls = JSON.parse(message.toolCalls); + } catch { + // Ignore parse errors for malformed tool calls + } + } + + if (!message.extra || message.extra.length === 0) { + const result: ApiChatMessageData = { + role: message.role as MessageRole, + content: message.content + }; + + if (message.reasoningContent) { + result.reasoning_content = message.reasoningContent; + } + + if (toolCalls && toolCalls.length > 0) { + result.tool_calls = toolCalls; + } + + return result; + } + + const contentParts: ApiChatMessageContentPart[] = []; + + if (message.content) { + contentParts.push({ + type: ContentPartType.TEXT, + text: message.content + }); + } + + // Include images from all messages + const imageFiles = message.extra.filter( + (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile => + extra.type === AttachmentType.IMAGE + ); + + for (const image of imageFiles) { + contentParts.push({ + type: ContentPartType.IMAGE_URL, + image_url: { url: image.base64Url } + }); + } + + const textFiles = message.extra.filter( + (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile => + extra.type === AttachmentType.TEXT + ); + + for (const textFile of textFiles) { + contentParts.push({ + type: ContentPartType.TEXT, + text: formatAttachmentText('File', textFile.name, textFile.content) + }); + } + + // Handle legacy 'context' type from the old UI (pasted content) + const legacyContextFiles = message.extra.filter( + (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraLegacyContext => + extra.type === AttachmentType.LEGACY_CONTEXT + ); + + for (const legacyContextFile of legacyContextFiles) { + contentParts.push({ + type: ContentPartType.TEXT, + text: formatAttachmentText('File', legacyContextFile.name, legacyContextFile.content) + }); + } + + const audioFiles = message.extra.filter( + (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraAudioFile => + extra.type === AttachmentType.AUDIO + ); + + for (const audio of audioFiles) { + contentParts.push({ + type: ContentPartType.INPUT_AUDIO, + input_audio: { + data: audio.base64Data, + format: audio.mimeType.includes('wav') ? 'wav' : 'mp3' + } + }); + } + + const pdfFiles = message.extra.filter( + (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile => + extra.type === AttachmentType.PDF + ); + + for (const pdfFile of pdfFiles) { + if (pdfFile.processedAsImages && pdfFile.images) { + for (let i = 0; i < pdfFile.images.length; i++) { + contentParts.push({ + type: ContentPartType.IMAGE_URL, + image_url: { url: pdfFile.images[i] } + }); + } + } else { + contentParts.push({ + type: ContentPartType.TEXT, + text: formatAttachmentText(ATTACHMENT_LABEL_PDF_FILE, pdfFile.name, pdfFile.content) + }); + } + } + + const mcpPrompts = message.extra.filter( + (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpPrompt => + extra.type === AttachmentType.MCP_PROMPT + ); + + for (const mcpPrompt of mcpPrompts) { + contentParts.push({ + type: ContentPartType.TEXT, + text: formatAttachmentText( + ATTACHMENT_LABEL_MCP_PROMPT, + mcpPrompt.name, + mcpPrompt.content, + mcpPrompt.serverName + ) + }); + } + + const mcpResources = message.extra.filter( + (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpResource => + extra.type === AttachmentType.MCP_RESOURCE + ); + + for (const mcpResource of mcpResources) { + contentParts.push({ + type: ContentPartType.TEXT, + text: formatAttachmentText( + ATTACHMENT_LABEL_MCP_RESOURCE, + mcpResource.name, + mcpResource.content, + mcpResource.serverName + ) + }); + } + + const result: ApiChatMessageData = { + role: message.role as MessageRole, + content: contentParts + }; + if (message.reasoningContent) { + result.reasoning_content = message.reasoningContent; + } + if (toolCalls && toolCalls.length > 0) { + result.tool_calls = toolCalls; + } + return result; + } + + /** + * + * + * Utilities + * + * + */ + + /** + * Strips legacy inline reasoning content tags from message content. + * Handles both plain string content and multipart content arrays. + */ + private static stripReasoningContent( + content: string | ApiChatMessageContentPart[] + ): string | ApiChatMessageContentPart[] { + const stripFromString = (text: string): string => + text.replace(LEGACY_AGENTIC_REGEX.REASONING_BLOCK, '').trim(); + + if (typeof content === 'string') { + return stripFromString(content); + } + + return content.map((part) => { + if (part.type === ContentPartType.TEXT && part.text) { + return { ...part, text: stripFromString(part.text) }; + } + return part; + }); + } + + /** + * Parses error response and creates appropriate error with context information + * @param response - HTTP response object + * @returns Promise - Parsed error with context info if available + */ + private static async parseErrorResponse( + response: Response + ): Promise { + try { + const errorText = await response.text(); + const errorData: ApiErrorResponse = JSON.parse(errorText); + + const message = errorData.error?.message || 'Unknown server error'; + const error = new Error(message) as Error & { + contextInfo?: { n_prompt_tokens: number; n_ctx: number }; + }; + error.name = response.status === 400 ? 'ServerError' : 'HttpError'; + + if (errorData.error && 'n_prompt_tokens' in errorData.error && 'n_ctx' in errorData.error) { + error.contextInfo = { + n_prompt_tokens: errorData.error.n_prompt_tokens, + n_ctx: errorData.error.n_ctx + }; + } + + return error; + } catch { + const fallback = new Error( + `Server error (${response.status}): ${response.statusText}` + ) as Error & { + contextInfo?: { n_prompt_tokens: number; n_ctx: number }; + }; + fallback.name = 'HttpError'; + + return fallback; + } + } + + /** + * Extracts model name from Chat Completions API response data. + * Handles various response formats including streaming chunks and final responses. + * + * WORKAROUND: In single model mode, llama-server returns a default/incorrect model name + * in the response. We override it with the actual model name from serverStore. + * + * @param data - Raw response data from the Chat Completions API + * @returns Model name string if found, undefined otherwise + * @private + */ + private static extractModelName(data: unknown): string | undefined { + const asRecord = (value: unknown): Record | undefined => { + return typeof value === 'object' && value !== null + ? (value as Record) + : undefined; + }; + + const getTrimmedString = (value: unknown): string | undefined => { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + }; + + const root = asRecord(data); + if (!root) return undefined; + + // 1) root (some implementations provide `model` at the top level) + const rootModel = getTrimmedString(root.model); + if (rootModel) { + return rootModel; + } + + // 2) streaming choice (delta) or final response (message) + const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined; + if (!firstChoice) { + return undefined; + } + + // priority: delta.model (first chunk) else message.model (final response) + const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model); + if (deltaModel) { + return deltaModel; + } + + const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model); + if (messageModel) { + return messageModel; + } + + // avoid guessing from non-standard locations (metadata, etc.) + return undefined; + } + + /** + * Calls the onTimings callback with timing data from streaming response. + * + * @param timings - Timing information from the Chat Completions API response + * @param promptProgress - Prompt processing progress data + * @param onTimingsCallback - Callback function to invoke with timing data + * @private + */ + private static notifyTimings( + timings: ChatMessageTimings | undefined, + promptProgress: ChatMessagePromptProgress | undefined, + onTimingsCallback: + | ((timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void) + | undefined + ): void { + if (!onTimingsCallback || (!timings && !promptProgress)) return; + + onTimingsCallback(timings, promptProgress); + } +} diff --git a/tools/ui/src/lib/services/database.service.ts b/tools/ui/src/lib/services/database.service.ts new file mode 100644 index 000000000..457867d98 --- /dev/null +++ b/tools/ui/src/lib/services/database.service.ts @@ -0,0 +1,515 @@ +import Dexie, { type EntityTable } from 'dexie'; +import { findDescendantMessages, uuid, filterByLeafNodeId } from '$lib/utils'; +import { IDXDB_TABLES, IDXDB_STORES, STORAGE_APP_NAME } from '$lib/constants'; +import { MessageRole } from '$lib/enums'; +import type { McpServerOverride } from '$lib/types/database'; + +class LlamaUiDatabase extends Dexie { + [IDXDB_TABLES.conversations]!: EntityTable; + [IDXDB_TABLES.messages]!: EntityTable; + + constructor() { + super(STORAGE_APP_NAME); + + this.version(1).stores(IDXDB_STORES); + } +} + +const db = new LlamaUiDatabase(); + +export class DatabaseService { + /** + * + * + * Conversations + * + * + */ + + /** + * Creates a new conversation. + * + * @param name - Name of the conversation + * @returns The created conversation + */ + static async createConversation(name: string): Promise { + const conversation: DatabaseConversation = { + id: uuid(), + name, + lastModified: Date.now(), + currNode: '' + }; + + await db[IDXDB_TABLES.conversations].add(conversation); + return conversation; + } + + /** + * + * + * Messages + * + * + */ + + /** + * Creates a new message branch by adding a message and updating parent/child relationships. + * Also updates the conversation's currNode to point to the new message. + * + * @param message - Message to add (without id) + * @param parentId - Parent message ID to attach to + * @returns The created message + */ + static async createMessageBranch( + message: Omit, + parentId: string | null + ): Promise { + return await db.transaction( + 'rw', + [db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]], + async () => { + // Handle null parent (root message case) + if (parentId !== null) { + const parentMessage = await db[IDXDB_TABLES.messages].get(parentId); + if (!parentMessage) { + throw new Error(`Parent message ${parentId} not found`); + } + } + + const newMessage: DatabaseMessage = { + ...message, + id: uuid(), + parent: parentId, + toolCalls: message.toolCalls ?? '', + children: [] + }; + + await db[IDXDB_TABLES.messages].add(newMessage); + + // Update parent's children array if parent exists + if (parentId !== null) { + const parentMessage = await db[IDXDB_TABLES.messages].get(parentId); + if (parentMessage) { + await db[IDXDB_TABLES.messages].update(parentId, { + children: [...parentMessage.children, newMessage.id] + }); + } + } + + await this.updateConversation(message.convId, { + currNode: newMessage.id + }); + + return newMessage; + } + ); + } + + /** + * Creates a root message for a new conversation. + * Root messages are not displayed but serve as the tree root for branching. + * + * @param convId - Conversation ID + * @returns The created root message + */ + static async createRootMessage(convId: string): Promise { + const rootMessage: DatabaseMessage = { + id: uuid(), + convId, + type: 'root', + timestamp: Date.now(), + role: MessageRole.SYSTEM, + content: '', + parent: null, + toolCalls: '', + children: [] + }; + + await db[IDXDB_TABLES.messages].add(rootMessage); + return rootMessage.id; + } + + /** + * Creates a system prompt message for a conversation. + * + * @param convId - Conversation ID + * @param systemPrompt - The system prompt content (must be non-empty) + * @param parentId - Parent message ID (typically the root message) + * @returns The created system message + * @throws Error if systemPrompt is empty + */ + static async createSystemMessage( + convId: string, + systemPrompt: string, + parentId: string + ): Promise { + const trimmedPrompt = systemPrompt.trim(); + if (!trimmedPrompt) { + throw new Error('Cannot create system message with empty content'); + } + + const systemMessage: DatabaseMessage = { + id: uuid(), + convId, + type: MessageRole.SYSTEM, + timestamp: Date.now(), + role: MessageRole.SYSTEM, + content: trimmedPrompt, + parent: parentId, + children: [] + }; + + await db[IDXDB_TABLES.messages].add(systemMessage); + + const parentMessage = await db[IDXDB_TABLES.messages].get(parentId); + if (parentMessage) { + await db[IDXDB_TABLES.messages].update(parentId, { + children: [...parentMessage.children, systemMessage.id] + }); + } + + return systemMessage; + } + + /** + * Deletes a conversation and all its messages. + * + * @param id - Conversation ID + */ + static async deleteConversation( + id: string, + options?: { deleteWithForks?: boolean } + ): Promise { + await db.transaction( + 'rw', + [db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]], + async () => { + if (options?.deleteWithForks) { + // Recursively collect all descendant IDs + const idsToDelete: string[] = []; + const queue = [id]; + + while (queue.length > 0) { + const parentId = queue.pop()!; + const children = await db[IDXDB_TABLES.conversations] + .filter((c) => c.forkedFromConversationId === parentId) + .toArray(); + + for (const child of children) { + idsToDelete.push(child.id); + queue.push(child.id); + } + } + + for (const forkId of idsToDelete) { + await db[IDXDB_TABLES.conversations].delete(forkId); + await db[IDXDB_TABLES.messages].where('convId').equals(forkId).delete(); + } + } else { + // Reparent direct children to deleted conv's parent + const conv = await db[IDXDB_TABLES.conversations].get(id); + const newParent = conv?.forkedFromConversationId; + const directChildren = await db[IDXDB_TABLES.conversations] + .filter((c) => c.forkedFromConversationId === id) + .toArray(); + + for (const child of directChildren) { + await db[IDXDB_TABLES.conversations].update(child.id, { + forkedFromConversationId: newParent ?? undefined + }); + } + } + + await db[IDXDB_TABLES.conversations].delete(id); + await db[IDXDB_TABLES.messages].where('convId').equals(id).delete(); + } + ); + } + + /** + * Deletes a message and removes it from its parent's children array. + * + * @param messageId - ID of the message to delete + */ + static async deleteMessage(messageId: string): Promise { + await db.transaction('rw', db[IDXDB_TABLES.messages], async () => { + const message = await db[IDXDB_TABLES.messages].get(messageId); + if (!message) return; + + // Remove this message from its parent's children array + if (message.parent) { + const parent = await db[IDXDB_TABLES.messages].get(message.parent); + if (parent) { + parent.children = parent.children.filter((childId: string) => childId !== messageId); + await db[IDXDB_TABLES.messages].put(parent); + } + } + + // Delete the message + await db[IDXDB_TABLES.messages].delete(messageId); + }); + } + + /** + * Deletes a message and all its descendant messages (cascading deletion). + * This removes the entire branch starting from the specified message. + * + * @param conversationId - ID of the conversation containing the message + * @param messageId - ID of the root message to delete (along with all descendants) + * @returns Array of all deleted message IDs + */ + static async deleteMessageCascading( + conversationId: string, + messageId: string + ): Promise { + return await db.transaction('rw', db[IDXDB_TABLES.messages], async () => { + // Get all messages in the conversation to find descendants + const allMessages = await db[IDXDB_TABLES.messages] + .where('convId') + .equals(conversationId) + .toArray(); + + // Find all descendant messages + const descendants = findDescendantMessages(allMessages, messageId); + const allToDelete = [messageId, ...descendants]; + + // Get the message to delete for parent cleanup + const message = await db[IDXDB_TABLES.messages].get(messageId); + if (message && message.parent) { + const parent = await db[IDXDB_TABLES.messages].get(message.parent); + if (parent) { + parent.children = parent.children.filter((childId: string) => childId !== messageId); + await db[IDXDB_TABLES.messages].put(parent); + } + } + + // Delete all messages in the branch + await db[IDXDB_TABLES.messages].bulkDelete(allToDelete); + + return allToDelete; + }); + } + + /** + * Gets all conversations, sorted by last modified time (newest first). + * + * @returns Array of conversations + */ + static async getAllConversations(): Promise { + return await db[IDXDB_TABLES.conversations].orderBy('lastModified').reverse().toArray(); + } + + /** + * Gets a conversation by ID. + * + * @param id - Conversation ID + * @returns The conversation if found, otherwise undefined + */ + static async getConversation(id: string): Promise { + return await db[IDXDB_TABLES.conversations].get(id); + } + + /** + * Gets all messages in a conversation, sorted by timestamp (oldest first). + * + * @param convId - Conversation ID + * @returns Array of messages in the conversation + */ + static async getConversationMessages(convId: string): Promise { + return await db[IDXDB_TABLES.messages].where('convId').equals(convId).sortBy('timestamp'); + } + + /** + * Updates a conversation. + * + * @param id - Conversation ID + * @param updates - Partial updates to apply + * @returns Promise that resolves when the conversation is updated + */ + static async updateConversation( + id: string, + updates: Partial> + ): Promise { + await db[IDXDB_TABLES.conversations].update(id, { + ...updates, + lastModified: Date.now() + }); + } + + /** + * + * + * Navigation + * + * + */ + + /** + * Updates the conversation's current node (active branch). + * This determines which conversation path is currently being viewed. + * + * @param convId - Conversation ID + * @param nodeId - Message ID to set as current node + */ + static async updateCurrentNode(convId: string, nodeId: string): Promise { + await this.updateConversation(convId, { + currNode: nodeId + }); + } + + /** + * Updates a message. + * + * @param id - Message ID + * @param updates - Partial updates to apply + * @returns Promise that resolves when the message is updated + */ + static async updateMessage( + id: string, + updates: Partial> + ): Promise { + await db[IDXDB_TABLES.messages].update(id, updates); + } + + /** + * + * + * Import + * + * + */ + + /** + * Imports multiple conversations and their messages. + * Skips conversations that already exist. + * + * @param data - Array of { conv, messages } objects + */ + static async importConversations( + data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[] + ): Promise<{ imported: number; skipped: number }> { + let importedCount = 0; + let skippedCount = 0; + + return await db.transaction( + 'rw', + [db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]], + async () => { + for (const item of data) { + const { conv, messages } = item; + + const existing = await db[IDXDB_TABLES.conversations].get(conv.id); + if (existing) { + console.warn(`Conversation "${conv.name}" already exists, skipping...`); + skippedCount++; + continue; + } + + await db[IDXDB_TABLES.conversations].add(conv); + for (const msg of messages) { + await db[IDXDB_TABLES.messages].put(msg); + } + + importedCount++; + } + + return { imported: importedCount, skipped: skippedCount }; + } + ); + } + + /** + * + * + * Forking + * + * + */ + + /** + * Forks a conversation at a specific message, creating a new conversation + * containing all messages from the root up to (and including) the target message. + * + * @param sourceConvId - The source conversation ID + * @param atMessageId - The message ID to fork at (the new conversation ends here) + * @param options - Fork options (name and whether to include attachments) + * @returns The newly created conversation + */ + static async forkConversation( + sourceConvId: string, + atMessageId: string, + options: { name: string; includeAttachments: boolean } + ): Promise { + return await db.transaction( + 'rw', + [db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]], + async () => { + const sourceConv = await db[IDXDB_TABLES.conversations].get(sourceConvId); + if (!sourceConv) { + throw new Error(`Source conversation ${sourceConvId} not found`); + } + + const allMessages = await db[IDXDB_TABLES.messages] + .where('convId') + .equals(sourceConvId) + .toArray(); + + const pathMessages = filterByLeafNodeId( + allMessages, + atMessageId, + true + ) as DatabaseMessage[]; + if (pathMessages.length === 0) { + throw new Error(`Could not resolve message path to ${atMessageId}`); + } + + const idMap = new Map(); + + for (const msg of pathMessages) { + idMap.set(msg.id, uuid()); + } + + const newConvId = uuid(); + const clonedMessages: DatabaseMessage[] = pathMessages.map((msg) => { + const newId = idMap.get(msg.id)!; + const newParent = msg.parent ? (idMap.get(msg.parent) ?? null) : null; + const newChildren = msg.children + .filter((childId: string) => idMap.has(childId)) + .map((childId: string) => idMap.get(childId)!); + + return { + ...msg, + id: newId, + convId: newConvId, + parent: newParent, + children: newChildren, + extra: options.includeAttachments ? msg.extra : undefined + }; + }); + + const lastClonedMessage = clonedMessages[clonedMessages.length - 1]; + const newConv: DatabaseConversation = { + id: newConvId, + name: options.name, + lastModified: Date.now(), + currNode: lastClonedMessage.id, + forkedFromConversationId: sourceConvId, + mcpServerOverrides: sourceConv.mcpServerOverrides + ? sourceConv.mcpServerOverrides.map((o: McpServerOverride) => ({ + serverId: o.serverId, + enabled: o.enabled + })) + : undefined + }; + + await db[IDXDB_TABLES.conversations].add(newConv); + + for (const msg of clonedMessages) { + await db[IDXDB_TABLES.messages].add(msg); + } + + return newConv; + } + ); + } +} diff --git a/tools/ui/src/lib/services/index.ts b/tools/ui/src/lib/services/index.ts new file mode 100644 index 000000000..475e6419b --- /dev/null +++ b/tools/ui/src/lib/services/index.ts @@ -0,0 +1,313 @@ +/** + * + * SERVICES + * + * Stateless service layer for API communication and data operations. + * Services handle protocol-level concerns (HTTP, WebSocket, MCP, IndexedDB) + * without managing reactive state — that responsibility belongs to stores. + * + * **Design Principles:** + * - All methods are static — no instance state + * - Pure I/O operations (network requests, database queries) + * - No Svelte runes or reactive primitives + * - Error handling at the protocol level; business-level error handling in stores + * + * **Architecture (bottom to top):** + * - **Services** (this layer): Stateless protocol communication + * - **Stores**: Reactive state management consuming services + * - **Components**: UI consuming stores + * + */ + +/** + * **ChatService** - Chat Completions API communication layer + * + * Handles direct communication with the llama-server's `/v1/chat/completions` endpoint. + * Provides streaming and non-streaming response parsing, message format conversion + * (DatabaseMessage → API format), and request lifecycle management. + * + * **Terminology - Chat vs Conversation:** + * - **Chat**: The active interaction space with the Chat Completions API. Ephemeral and + * runtime-focused — sending messages, receiving streaming responses, managing request lifecycles. + * - **Conversation**: The persistent database entity storing all messages and metadata. + * Managed by conversationsStore, conversations persist across sessions. + * + * **Architecture & Relationships:** + * - **ChatService** (this class): Stateless API communication layer + * - Handles HTTP requests/responses with the llama-server + * - Manages streaming and non-streaming response parsing + * - Converts database messages to API format (multimodal, tool calls) + * - Handles error translation with user-friendly messages + * + * - **chatStore**: Primary consumer — uses ChatService for all AI model communication + * - **agenticStore**: Uses ChatService for multi-turn agentic loop streaming + * - **conversationsStore**: Provides message context for API requests + * + * **Key Responsibilities:** + * - Streaming response handling with real-time content/reasoning/tool-call callbacks + * - Non-streaming response parsing with complete response extraction + * - Database message to API format conversion (attachments, tool calls, multimodal) + * - Tool call delta merging for incremental streaming aggregation + * - Request parameter assembly (sampling, penalties, custom params) + * - File attachment processing (images, PDFs, audio, text, MCP prompts/resources) + * - Reasoning content stripping from prompt history to avoid KV cache pollution + * - Error translation (network, timeout, server errors → user-friendly messages) + * + * @see chatStore in stores/chat.svelte.ts — primary consumer for chat state management + * @see agenticStore in stores/agentic.svelte.ts — uses ChatService for agentic loop streaming + * @see conversationsStore in stores/conversations.svelte.ts — provides message context + */ +export { ChatService } from './chat.service'; + +/** + * **DatabaseService** - IndexedDB persistence layer via Dexie ORM + * + * Provides stateless data access for conversations and messages using IndexedDB. + * Handles all low-level storage operations including branching tree structures, + * cascade deletions, and transaction safety for multi-table operations. + * + * **Architecture & Relationships (bottom to top):** + * - **DatabaseService** (this class): Stateless IndexedDB operations + * - Lowest layer — direct Dexie/IndexedDB communication + * - Pure CRUD operations without business logic + * - Handles branching tree structure (parent-child relationships) + * - Provides transaction safety for multi-table operations + * + * - **conversationsStore**: Reactive state management layer + * - Uses DatabaseService for all persistence operations + * - Manages conversation list, active conversation, and messages in memory + * + * - **chatStore**: Active AI interaction management + * - Uses conversationsStore for conversation context + * - Directly uses DatabaseService for message CRUD during streaming + * + * **Key Responsibilities:** + * - Conversation CRUD (create, read, update, delete) + * - Message CRUD with branching support (parent-child relationships) + * - Root message and system prompt creation + * - Cascade deletion of message branches (descendants) + * - Transaction-safe multi-table operations + * - Conversation import with duplicate detection + * + * **Database Schema:** + * - `conversations`: id, lastModified, currNode, name + * - `messages`: id, convId, type, role, timestamp, parent, children + * + * **Branching Model:** + * Messages form a tree structure where each message can have multiple children, + * enabling conversation branching and alternative response paths. The conversation's + * `currNode` tracks the currently active branch endpoint. + * + * @see conversationsStore in stores/conversations.svelte.ts — reactive layer on top of DatabaseService + * @see chatStore in stores/chat.svelte.ts — uses DatabaseService directly for message CRUD during streaming + */ +export { DatabaseService } from './database.service'; + +/** + * **ModelsService** - Model management API communication + * + * Handles communication with model-related endpoints for both MODEL (single model) + * and ROUTER (multi-model) server modes. Provides model listing, loading/unloading, + * and status checking without managing any model state. + * + * **Architecture & Relationships:** + * - **ModelsService** (this class): Stateless HTTP communication + * - Sends requests to model endpoints + * - Parses and returns typed API responses + * - Provides model status utility methods + * + * - **modelsStore**: Primary consumer — manages reactive model state + * - Calls ModelsService for all model API operations + * - Handles polling, caching, and state updates + * + * **Key Responsibilities:** + * - List available models via OpenAI-compatible `/v1/models` endpoint + * - Load/unload models via `/models/load` and `/models/unload` (ROUTER mode) + * - Model status queries (loaded, loading) + * + * **Server Mode Behavior:** + * - **MODEL mode**: Only `list()` is relevant — single model always loaded + * - **ROUTER mode**: Full lifecycle — `list()`, `listRouter()`, `load()`, `unload()` + * + * **Endpoints:** + * - `GET /v1/models` — OpenAI-compatible model list (both modes) + * - `POST /models/load` — Load a model (ROUTER mode only) + * - `POST /models/unload` — Unload a model (ROUTER mode only) + * + * @see modelsStore in stores/models.svelte.ts — primary consumer for reactive model state + */ +export { ModelsService } from './models.service'; + +/** + * **PropsService** - Server properties and capabilities retrieval + * + * Fetches server configuration, model information, and capabilities from the `/props` + * endpoint. Supports both global server props and per-model props (ROUTER mode). + * + * **Architecture & Relationships:** + * - **PropsService** (this class): Stateless HTTP communication + * - Fetches server properties from `/props` endpoint + * - Handles authentication and request parameters + * - Returns typed `ApiLlamaCppServerProps` responses + * + * - **serverStore**: Consumes global server properties (role detection, connection state) + * - **modelsStore**: Consumes per-model properties (modalities, context size) + * - **settingsStore**: Syncs default generation parameters from props response + * + * **Key Responsibilities:** + * - Fetch global server properties (default generation settings, modalities) + * - Fetch per-model properties in ROUTER mode via `?model=` parameter + * - Handle autoload control to prevent unintended model loading + * + * **API Behavior:** + * - `GET /props` → Global server props (MODEL mode: includes modalities) + * - `GET /props?model=` → Per-model props (ROUTER mode: model-specific modalities) + * - `&autoload=false` → Prevents model auto-loading when querying props + * + * @see serverStore in stores/server.svelte.ts — consumes global server props + * @see modelsStore in stores/models.svelte.ts — consumes per-model props for modalities + * @see settingsStore in stores/settings.svelte.ts — syncs default generation params from props + */ +export { PropsService } from './props.service'; + +/** + * **ParameterSyncService** - Server defaults and user settings synchronization + * + * Manages the complex logic of merging server-provided default parameters with + * user-configured overrides. Ensures the UI reflects the actual server state + * while preserving user customizations. Tracks parameter sources (server default + * vs user override) for display in the settings UI. + * + * **Architecture & Relationships:** + * - **ParameterSyncService** (this class): Stateless sync logic + * - Pure functions for parameter extraction, merging, and diffing + * - No side effects — receives data in, returns data out + * - Handles floating-point precision normalization + * + * - **settingsStore**: Primary consumer — calls sync methods during: + * - Initial load (`syncWithServerDefaults`) + * - Settings reset (`forceSyncWithServerDefaults`) + * - Parameter info queries (`getParameterInfo`) + * + * - **PropsService**: Provides raw server props that feed into extraction + * + * **Key Responsibilities:** + * - Extract syncable parameters from server `/props` response + * - Merge server defaults with user overrides (user wins) + * - Track parameter source (Custom vs Default) for UI badges + * - Validate server parameter values by type (number, string, boolean) + * - Create diffs between current settings and server defaults + * - Floating-point precision normalization for consistent comparisons + * + * **Parameter Source Priority:** + * 1. **User Override** (Custom badge) — explicitly set by user in settings + * 2. **Server Default** (Default badge) — from `/props` endpoint + * 3. **App Default** — hardcoded fallback when server props unavailable + * + * **Exports:** + * - `ParameterSyncService` class — static methods for sync logic + * - `SYNCABLE_PARAMETERS` — mapping of UI setting keys to server parameter keys + * + * @see settingsStore in stores/settings.svelte.ts — primary consumer for settings sync + * @see SettingsChatParameterSourceIndicator — displays parameter source badges in UI + */ +export { ParameterSyncService } from './parameter-sync.service'; + +/** + * **MCPService** - Low-level MCP protocol communication layer + * + * Implements the client-side MCP (Model Context Protocol) SDK operations for connecting + * to MCP servers, discovering capabilities, and executing protocol operations. + * Supports multiple transport types: WebSocket, StreamableHTTP, and SSE (legacy fallback). + * + * **Architecture & Relationships:** + * - **MCPService** (this class): Stateless protocol communication + * - Creates and manages transport connections (WebSocket, StreamableHTTP, SSE) + * - Wraps MCP SDK client operations with error handling + * - Formats tool results and extracts server info + * - Provides abort signal support for cancellable operations + * + * - **mcpStore**: Reactive business logic facade + * - Uses MCPService for all protocol-level operations + * - Manages connection lifecycle, health checks, reconnection + * - Handles tool name conflict resolution and server coordination + * + * - **mcpResourceStore**: Reactive resource state + * - Receives resource data fetched via MCPService + * - Manages resource caching, subscriptions, and attachments + * + * - **agenticStore**: Agentic loop orchestration + * - Executes tool calls via mcpStore → MCPService chain + * + * **Key Responsibilities:** + * - Transport creation with automatic fallback (StreamableHTTP → SSE) + * - Server connection with detailed phase tracking and progress callbacks + * - Tool discovery (`listTools`) and execution (`callTool`) with abort support + * - Prompt listing (`listPrompts`) and retrieval (`getPrompt`) with arguments + * - Resource operations: list, read, subscribe/unsubscribe, template support + * - Completion suggestions for prompt arguments and resource URI templates + * - CORS proxy routing via llama-server for cross-origin MCP servers + * - Tool result formatting (text, images, embedded resources) + * + * **Transport Hierarchy:** + * 1. **WebSocket** — bidirectional, no CORS proxy support + * 2. **StreamableHTTP** — modern HTTP-based, supports CORS proxy + * 3. **SSE** — legacy fallback, supports CORS proxy + * + * @see mcpStore in stores/mcp.svelte.ts — reactive business logic facade on top of MCPService + * @see mcpResourceStore in stores/mcp-resources.svelte.ts — reactive resource state management + * @see agenticStore in stores/agentic.svelte.ts — uses MCPService (via mcpStore) for tool execution + * @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18 + */ +export { MCPService } from './mcp.service'; + +/** + * **RouterService** — Dynamic route URL construction utility + * + * Stateless utility for building dynamic route URLs from ROUTES base paths. + * Static routes (START, NEW_CHAT, MCP_SERVERS) live in ROUTES constants; + * dynamic routes (CHAT, SETTINGS) are constructed here by appending parameters. + * + * **Architecture & Relationships:** + * - **RouterService** (this class): Stateless URL construction + * - Builds dynamic route URLs from ROUTES base paths + * - No side effects — receives route parameters, returns route strings + * + * - **ROUTES constant** (constants/routes.ts): Static route base paths + * - **All components/stores**: Call RouterService for dynamic route URLs + * + * **Key Responsibilities:** + * - Build chat URLs for specific conversations: `RouterService.chat(id)` → `#/chat/:id` + * - Build settings URLs for sections: `RouterService.settings(section)` → `#/settings/:section` + * + * @see ROUTES in constants/routes.ts — static route base paths + */ +export { RouterService } from './router.service'; + +/** + * **MigrationService** — Unified data migration hook + * + * Centralizes all data migrations (localStorage, IndexedDB, legacy formats) into a single + * initialization point. All migrations are NON-DESTRUCTIVE - legacy data is preserved + * for downgrade compatibility (no rollback needed). + * + * **Current Migrations:** + * 1. **localStorage prefix**: Copy LlamaCppWebui.* → LlamaUi.* (both preserved) + * 2. **IndexedDB database**: Copy LlamacppWebui → LlamaUi (both preserved) + * 3. **Legacy message format**: Marker-based → Structured format + * 4. **Theme key**: Copy standalone `theme` → config object (both preserved) + * + * **Usage:** + * ```typescript + * import { MigrationService } from '$lib/services'; + * + * // Run all migrations on app startup (non-destructive) + * await MigrationService.runAllMigrations(); + * + * // Check migration status + * const state = MigrationService.getState(); + * ``` + * + * @see migration.service.ts — full implementation (non-destructive) + */ +export { MigrationService } from './migration.service'; diff --git a/tools/ui/src/lib/services/mcp.service.ts b/tools/ui/src/lib/services/mcp.service.ts new file mode 100644 index 000000000..458013b5a --- /dev/null +++ b/tools/ui/src/lib/services/mcp.service.ts @@ -0,0 +1,1110 @@ +import { Client } from '@modelcontextprotocol/sdk/client'; +import { + StreamableHTTPClientTransport, + StreamableHTTPError +} from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; +import type { + Tool, + Prompt, + GetPromptResult, + ListChangedHandlers +} from '@modelcontextprotocol/sdk/types.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { + DEFAULT_MCP_CONFIG, + DEFAULT_CLIENT_VERSION, + DEFAULT_IMAGE_MIME_TYPE, + MCP_PARTIAL_REDACT_HEADERS +} from '$lib/constants'; +import { + MCPConnectionPhase, + MCPLogLevel, + MCPTransportType, + MCPContentType, + MCPRefType +} from '$lib/enums'; +import type { + MCPServerConfig, + MCPResourceIcon, + ToolCallParams, + ToolExecutionResult, + Implementation, + ClientCapabilities, + MCPConnection, + MCPPhaseCallback, + MCPConnectionLog, + MCPServerInfo, + MCPResource, + MCPResourceTemplate, + MCPResourceContent, + MCPReadResourceResult +} from '$lib/types'; +import { + buildProxiedUrl, + buildProxiedHeaders, + getAuthHeaders, + sanitizeHeaders, + throwIfAborted, + isAbortError, + createBase64DataUrl, + getRequestUrl, + getRequestMethod, + getRequestBody, + summarizeRequestBody, + formatDiagnosticErrorMessage, + extractJsonRpcMethods, + type RequestBodySummary +} from '$lib/utils'; + +interface ToolResultContentItem { + type: string; + text?: string; + data?: string; + mimeType?: string; + resource?: { text?: string; blob?: string; uri?: string }; +} + +interface ToolCallResult { + content?: ToolResultContentItem[]; + isError?: boolean; + _meta?: Record; +} + +interface DiagnosticRequestDetails { + url: string; + method: string; + credentials?: RequestCredentials; + mode?: RequestMode; + headers: Record; + body: RequestBodySummary; + jsonRpcMethods?: string[]; +} + +export class MCPService { + /** + * Create a connection log entry for phase tracking. + * + * @param phase - The connection phase this log belongs to + * @param message - Human-readable log message + * @param level - Log severity level (default: INFO) + * @param details - Optional structured details for debugging + * @returns Formatted connection log entry + */ + private static createLog( + phase: MCPConnectionPhase, + message: string, + level: MCPLogLevel = MCPLogLevel.INFO, + details?: unknown + ): MCPConnectionLog { + return { + timestamp: new Date(), + phase, + message, + level, + details + }; + } + + private static createDiagnosticRequestDetails( + input: RequestInfo | URL, + init: RequestInit | undefined, + baseInit: RequestInit, + requestHeaders: Headers, + extraRedactedHeaders?: Iterable + ): DiagnosticRequestDetails { + const body = getRequestBody(input, init); + const details: DiagnosticRequestDetails = { + url: getRequestUrl(input), + method: getRequestMethod(input, init, baseInit).toUpperCase(), + credentials: init?.credentials ?? baseInit.credentials, + mode: init?.mode ?? baseInit.mode, + headers: sanitizeHeaders(requestHeaders, extraRedactedHeaders, MCP_PARTIAL_REDACT_HEADERS), + body: summarizeRequestBody(body) + }; + const jsonRpcMethods = extractJsonRpcMethods(body); + + if (jsonRpcMethods) { + details.jsonRpcMethods = jsonRpcMethods; + } + + return details; + } + + private static summarizeError(error: unknown): Record { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + cause: + error.cause instanceof Error + ? { name: error.cause.name, message: error.cause.message } + : error.cause, + stack: error.stack?.split('\n').slice(0, 6).join('\n') + }; + } + + return { value: String(error) }; + } + + private static getBrowserContext( + targetUrl: URL, + useProxy: boolean + ): Record | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + return { + location: window.location.href, + origin: window.location.origin, + protocol: window.location.protocol, + isSecureContext: window.isSecureContext, + targetOrigin: targetUrl.origin, + targetProtocol: targetUrl.protocol, + sameOrigin: window.location.origin === targetUrl.origin, + useProxy + }; + } + + private static getConnectionHints( + targetUrl: URL, + config: MCPServerConfig, + error: unknown + ): string[] { + const hints: string[] = []; + const message = error instanceof Error ? error.message : String(error); + const headerNames = Object.keys(config.headers ?? {}); + + if (typeof window !== 'undefined') { + if ( + window.location.protocol === 'https:' && + targetUrl.protocol === 'http:' && + !config.useProxy + ) { + hints.push( + 'The page is running over HTTPS but the MCP server is HTTP. Browsers often block this as mixed content; enable the proxy or use HTTPS/WSS for the MCP server.' + ); + } + + if (window.location.origin !== targetUrl.origin && !config.useProxy) { + hints.push( + 'This is a cross-origin browser request. If the server is reachable from curl or Node but not from the browser, missing CORS headers are the most likely cause.' + ); + } + } + + if (headerNames.length > 0) { + hints.push( + `Custom request headers are configured (${headerNames.join(', ')}). That triggers a CORS preflight, so the server must allow OPTIONS and include the matching Access-Control-Allow-Headers response.` + ); + } + + if (config.credentials && config.credentials !== 'omit') { + hints.push( + 'Credentials are enabled for this connection. Cross-origin credentialed requests need Access-Control-Allow-Credentials: true and cannot use a wildcard Access-Control-Allow-Origin.' + ); + } + + if (message.includes('Failed to fetch')) { + hints.push( + '"Failed to fetch" is a browser-level network failure. Common causes are CORS rejection, mixed-content blocking, certificate/TLS errors, DNS failures, or nothing listening on the target port.' + ); + } + + return hints; + } + + private static createDiagnosticFetch( + serverName: string, + config: MCPServerConfig, + baseInit: RequestInit, + targetUrl: URL, + useProxy: boolean, + onLog?: (log: MCPConnectionLog) => void + ): { + fetch: typeof fetch; + disable: () => void; + } { + let enabled = true; + const logIfEnabled = (log: MCPConnectionLog) => { + if (enabled) { + onLog?.(log); + } + }; + + return { + fetch: async (input, init) => { + const startedAt = performance.now(); + const requestHeaders = new Headers(baseInit.headers); + + if (typeof Request !== 'undefined' && input instanceof Request) { + for (const [key, value] of input.headers.entries()) { + requestHeaders.set(key, value); + } + } + + if (init?.headers) { + for (const [key, value] of new Headers(init.headers).entries()) { + requestHeaders.set(key, value); + } + } + + const request = this.createDiagnosticRequestDetails( + input, + init, + baseInit, + requestHeaders, + Object.keys(config.headers ?? {}) + ); + const { method, url } = request; + + logIfEnabled( + this.createLog( + MCPConnectionPhase.INITIALIZING, + `HTTP ${method} ${url}`, + MCPLogLevel.INFO, + { + serverName, + request + } + ) + ); + + try { + const response = await fetch(input, { + ...baseInit, + ...init, + headers: requestHeaders + }); + const durationMs = Math.round(performance.now() - startedAt); + + logIfEnabled( + this.createLog( + MCPConnectionPhase.INITIALIZING, + `HTTP ${response.status} ${method} ${url} (${durationMs}ms)`, + response.ok ? MCPLogLevel.INFO : MCPLogLevel.WARN, + { + response: { + url, + status: response.status, + statusText: response.statusText, + headers: sanitizeHeaders(response.headers, undefined, MCP_PARTIAL_REDACT_HEADERS), + durationMs + } + } + ) + ); + + return response; + } catch (error) { + const durationMs = Math.round(performance.now() - startedAt); + + logIfEnabled( + this.createLog( + MCPConnectionPhase.ERROR, + `HTTP ${method} ${url} failed: ${formatDiagnosticErrorMessage(error)}`, + MCPLogLevel.ERROR, + { + serverName, + request, + error: this.summarizeError(error), + browser: this.getBrowserContext(targetUrl, useProxy), + hints: this.getConnectionHints(targetUrl, config, error), + durationMs + } + ) + ); + + throw error; + } + }, + disable: () => { + enabled = false; + } + }; + } + + /** + * Detect if an error indicates an expired/invalidated MCP session. + * Per MCP spec 2025-11-25: HTTP 404 means session invalidated, client MUST + * discard its session ID and start a new session with a fresh initialize request. + * + * @param error - The caught error to inspect + * @returns true if the error is a StreamableHTTP 404 (session not found) + */ + static isSessionExpiredError(error: unknown): boolean { + return error instanceof StreamableHTTPError && error.code === 404; + } + + /** + * Create transport based on server configuration. + * Supports WebSocket, StreamableHTTP (modern), and SSE (legacy) transports. + * When `useProxy` is enabled, routes HTTP requests through llama-server's CORS proxy. + * + * **Fallback Order:** + * 1. WebSocket — if explicitly configured (no CORS proxy support) + * 2. StreamableHTTP — default for HTTP connections + * 3. SSE — automatic fallback if StreamableHTTP fails + * + * @param config - Server configuration with url, transport type, proxy, and auth settings + * @returns Object containing the created transport and the transport type used + * @throws {Error} If url is missing, WebSocket + proxy combination, or all transports fail + */ + static createTransport( + serverName: string, + config: MCPServerConfig, + onLog?: (log: MCPConnectionLog) => void + ): { + transport: Transport; + type: MCPTransportType; + stopPhaseLogging: () => void; + } { + if (!config.url) { + throw new Error('MCP server configuration is missing url'); + } + + const useProxy = config.useProxy ?? false; + const requestInit: RequestInit = {}; + + if (config.headers) { + requestInit.headers = config.useProxy ? buildProxiedHeaders(config.headers) : config.headers; + } + + if (useProxy) { + requestInit.headers = { + ...getAuthHeaders(), + ...(requestInit.headers as Record) + }; + } + + if (config.credentials) { + requestInit.credentials = config.credentials; + } + + if (config.transport === MCPTransportType.WEBSOCKET) { + if (useProxy) { + throw new Error( + 'WebSocket transport is not supported when using CORS proxy. Use HTTP transport instead.' + ); + } + + const url = new URL(config.url); + + if (import.meta.env.DEV) { + console.log(`[MCPService] Creating WebSocket transport for ${url.href}`); + } + + return { + transport: new WebSocketClientTransport(url), + type: MCPTransportType.WEBSOCKET, + stopPhaseLogging: () => {} + }; + } + + const url = useProxy ? buildProxiedUrl(config.url) : new URL(config.url); + const { fetch: diagnosticFetch, disable: stopPhaseLogging } = this.createDiagnosticFetch( + serverName, + config, + requestInit, + url, + useProxy, + onLog + ); + + if (useProxy && import.meta.env.DEV) { + console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`); + } + + try { + if (import.meta.env.DEV) { + console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`); + } + + return { + transport: new StreamableHTTPClientTransport(url, { + requestInit, + fetch: diagnosticFetch + }), + type: MCPTransportType.STREAMABLE_HTTP, + stopPhaseLogging + }; + } catch (httpError) { + console.warn(`[MCPService] StreamableHTTP failed, trying SSE transport...`, httpError); + + try { + return { + transport: new SSEClientTransport(url, { + requestInit, + fetch: diagnosticFetch, + eventSourceInit: { fetch: diagnosticFetch } + }), + type: MCPTransportType.SSE, + stopPhaseLogging + }; + } catch (sseError) { + const httpMsg = httpError instanceof Error ? httpError.message : String(httpError); + const sseMsg = sseError instanceof Error ? sseError.message : String(sseError); + + throw new Error(`Failed to create transport. StreamableHTTP: ${httpMsg}; SSE: ${sseMsg}`); + } + } + } + + /** + * Extract server info from SDK Implementation type. + * Normalizes the SDK's server version response into our MCPServerInfo type. + * + * @param impl - Raw Implementation object from MCP SDK + * @returns Normalized server info or undefined if input is empty + */ + private static extractServerInfo(impl: Implementation | undefined): MCPServerInfo | undefined { + if (!impl) { + return undefined; + } + + return { + name: impl.name, + version: impl.version, + title: impl.title, + description: impl.description, + websiteUrl: impl.websiteUrl, + icons: impl.icons?.map((icon: MCPResourceIcon) => ({ + src: icon.src, + mimeType: icon.mimeType, + sizes: icon.sizes, + theme: icon.theme + })) + }; + } + + /** + * Connect to a single MCP server with detailed phase tracking. + * + * Performs the full MCP connection lifecycle: + * 1. Transport creation (with automatic fallback) + * 2. Client initialization and capability exchange + * 3. Tool discovery via `listTools` + * + * Reports progress via `onPhase` callback at each step, enabling + * UI progress indicators during connection. + * + * @param serverName - Display name for the server (used in logging) + * @param serverConfig - Server URL, transport type, proxy, and auth configuration + * @param clientInfo - Optional client identification (defaults to app info) + * @param capabilities - Optional client capability declaration + * @param onPhase - Optional callback for connection phase progress updates + * @param listChangedHandlers - Optional handlers for server-initiated list change notifications + * @returns Full connection object with client, transport, tools, server info, and timing + * @throws {Error} If transport creation or connection fails + */ + static async connect( + serverName: string, + serverConfig: MCPServerConfig, + clientInfo?: Implementation, + capabilities?: ClientCapabilities, + onPhase?: MCPPhaseCallback, + listChangedHandlers?: ListChangedHandlers + ): Promise { + const startTime = performance.now(); + const effectiveClientInfo = clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo; + const effectiveCapabilities = capabilities ?? DEFAULT_MCP_CONFIG.capabilities; + + // Phase: Creating transport + onPhase?.( + MCPConnectionPhase.TRANSPORT_CREATING, + this.createLog( + MCPConnectionPhase.TRANSPORT_CREATING, + `Creating transport for ${serverConfig.url}` + ) + ); + + if (import.meta.env.DEV) { + console.log(`[MCPService][${serverName}] Creating transport...`); + } + + const { + transport, + type: transportType, + stopPhaseLogging + } = this.createTransport(serverName, serverConfig, (log) => onPhase?.(log.phase, log)); + + // Setup WebSocket reconnection handler + if (transportType === MCPTransportType.WEBSOCKET) { + transport.onclose = () => { + console.log(`[MCPService][${serverName}] WebSocket closed, notifying for reconnection`); + onPhase?.( + MCPConnectionPhase.DISCONNECTED, + this.createLog(MCPConnectionPhase.DISCONNECTED, 'WebSocket connection closed') + ); + }; + } + + // Phase: Transport ready + onPhase?.( + MCPConnectionPhase.TRANSPORT_READY, + this.createLog(MCPConnectionPhase.TRANSPORT_READY, `Transport ready (${transportType})`), + { transportType } + ); + + const client = new Client( + { + name: effectiveClientInfo.name, + version: effectiveClientInfo.version ?? DEFAULT_CLIENT_VERSION + }, + { + capabilities: effectiveCapabilities, + listChanged: listChangedHandlers + } + ); + + const runtimeErrorHandler = (error: Error) => { + console.error(`[MCPService][${serverName}] Protocol error after initialize:`, error); + }; + + client.onerror = (error) => { + onPhase?.( + MCPConnectionPhase.ERROR, + this.createLog( + MCPConnectionPhase.ERROR, + `Protocol error: ${error.message}`, + MCPLogLevel.ERROR, + { + error: this.summarizeError(error) + } + ) + ); + }; + + // Phase: Initializing + onPhase?.( + MCPConnectionPhase.INITIALIZING, + this.createLog(MCPConnectionPhase.INITIALIZING, 'Sending initialize request...') + ); + + try { + await client.connect(transport); + // Transport diagnostics are only for the initial handshake, not long-lived traffic. + stopPhaseLogging(); + client.onerror = runtimeErrorHandler; + } catch (error) { + client.onerror = runtimeErrorHandler; + const url = + (serverConfig.useProxy ?? false) + ? buildProxiedUrl(serverConfig.url) + : new URL(serverConfig.url); + + onPhase?.( + MCPConnectionPhase.ERROR, + this.createLog( + MCPConnectionPhase.ERROR, + `Connection failed during initialize: ${ + error instanceof Error ? error.message : String(error) + }`, + MCPLogLevel.ERROR, + { + error: this.summarizeError(error), + config: { + serverName, + configuredUrl: serverConfig.url, + effectiveUrl: url.href, + transportType, + useProxy: serverConfig.useProxy ?? false, + headers: sanitizeHeaders( + serverConfig.headers, + Object.keys(serverConfig.headers ?? {}), + MCP_PARTIAL_REDACT_HEADERS + ), + credentials: serverConfig.credentials + }, + browser: this.getBrowserContext(url, serverConfig.useProxy ?? false), + hints: this.getConnectionHints(url, serverConfig, error) + } + ) + ); + + throw error; + } + + const serverVersion = client.getServerVersion(); + const serverCapabilities = client.getServerCapabilities(); + const instructions = client.getInstructions(); + const serverInfo = this.extractServerInfo(serverVersion); + + // Phase: Capabilities exchanged + onPhase?.( + MCPConnectionPhase.CAPABILITIES_EXCHANGED, + this.createLog( + MCPConnectionPhase.CAPABILITIES_EXCHANGED, + 'Capabilities exchanged successfully', + MCPLogLevel.INFO, + { + serverCapabilities, + serverInfo + } + ), + { + serverInfo, + serverCapabilities, + clientCapabilities: effectiveCapabilities, + instructions + } + ); + + // Phase: Listing tools + onPhase?.( + MCPConnectionPhase.LISTING_TOOLS, + this.createLog(MCPConnectionPhase.LISTING_TOOLS, 'Listing available tools...') + ); + + console.log(`[MCPService][${serverName}] Connected, listing tools...`); + const tools = await this.listTools({ + client, + transport, + tools: [], + serverName, + transportType, + connectionTimeMs: 0 + }); + + const connectionTimeMs = Math.round(performance.now() - startTime); + + // Phase: Connected + onPhase?.( + MCPConnectionPhase.CONNECTED, + this.createLog( + MCPConnectionPhase.CONNECTED, + `Connection established with ${tools.length} tools (${connectionTimeMs}ms)` + ) + ); + + console.log( + `[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms` + ); + + return { + client, + transport, + tools, + serverName, + transportType, + serverInfo, + serverCapabilities, + clientCapabilities: effectiveCapabilities, + protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion, + instructions, + connectionTimeMs + }; + } + + /** + * Disconnect from a server. + * Clears the `onclose` handler to prevent reconnection attempts on voluntary disconnect. + * + * @param connection - The active MCP connection to close + */ + static async disconnect(connection: MCPConnection): Promise { + console.log(`[MCPService][${connection.serverName}] Disconnecting...`); + try { + // Prevent reconnection on voluntary disconnect + if (connection.transport.onclose) { + connection.transport.onclose = undefined; + } + + await connection.client.close(); + } catch (error) { + console.warn(`[MCPService][${connection.serverName}] Error during disconnect:`, error); + } + } + + /** + * List tools from a connection. + * Silently returns empty array on failure (logged as warning). + * + * @param connection - The MCP connection to query + * @returns Array of available tools, or empty array on error + */ + static async listTools(connection: MCPConnection): Promise { + try { + const result = await connection.client.listTools(); + + return result.tools ?? []; + } catch (error) { + // Let session-expired errors propagate for reconnection handling + if (this.isSessionExpiredError(error)) { + throw error; + } + + console.warn(`[MCPService][${connection.serverName}] Failed to list tools:`, error); + + return []; + } + } + + /** + * List prompts from a connection. + * Silently returns empty array on failure (logged as warning). + * + * @param connection - The MCP connection to query + * @returns Array of available prompts, or empty array on error + */ + static async listPrompts(connection: MCPConnection): Promise { + try { + const result = await connection.client.listPrompts(); + + return result.prompts ?? []; + } catch (error) { + // Let session-expired errors propagate for reconnection handling + if (this.isSessionExpiredError(error)) { + throw error; + } + + console.warn(`[MCPService][${connection.serverName}] Failed to list prompts:`, error); + + return []; + } + } + + /** + * Get a specific prompt with arguments. + * Unlike list operations, this throws on failure since the caller explicitly + * requested a specific prompt and needs to handle the error. + * + * @param connection - The MCP connection to use + * @param name - The prompt name to retrieve + * @param args - Optional key-value arguments to pass to the prompt + * @returns The prompt result with messages and metadata + * @throws {Error} If the prompt retrieval fails + */ + static async getPrompt( + connection: MCPConnection, + name: string, + args?: Record + ): Promise { + try { + return await connection.client.getPrompt({ name, arguments: args }); + } catch (error) { + console.error(`[MCPService][${connection.serverName}] Failed to get prompt:`, error); + + throw error; + } + } + + /** + * Execute a tool call on a connection. + * Supports abort signal for cancellable operations (e.g., when user stops generation). + * Formats the raw tool result into a string representation. + * + * @param connection - The MCP connection to execute against + * @param params - Tool name and arguments to execute + * @param signal - Optional AbortSignal for cancellation support + * @returns Formatted tool execution result with content string and error flag + * @throws {Error} If tool execution fails or is aborted + */ + static async callTool( + connection: MCPConnection, + params: ToolCallParams, + signal?: AbortSignal + ): Promise { + throwIfAborted(signal); + + try { + const result = await connection.client.callTool( + { name: params.name, arguments: params.arguments }, + undefined, + { signal } + ); + + return { + content: this.formatToolResult(result as ToolCallResult), + isError: (result as ToolCallResult).isError ?? false + }; + } catch (error) { + if (isAbortError(error)) { + throw error; + } + + // Let session-expired errors propagate unwrapped for reconnection handling + if (this.isSessionExpiredError(error)) { + throw error; + } + + const message = error instanceof Error ? error.message : String(error); + + throw new Error( + `Tool "${params.name}" execution failed on server "${connection.serverName}": ${message}`, + { cause: error instanceof Error ? error : undefined } + ); + } + } + + /** + * Format tool result content items to a single string. + * Handles text, image (base64 data URL), and embedded resource content types. + * + * @param result - Raw tool call result from MCP SDK + * @returns Concatenated string representation of all content items + */ + private static formatToolResult(result: ToolCallResult): string { + const content = result.content; + if (!Array.isArray(content)) return ''; + + return content + .map((item) => this.formatSingleContent(item)) + .filter(Boolean) + .join('\n'); + } + + private static formatSingleContent(content: ToolResultContentItem): string { + if (content.type === MCPContentType.TEXT && content.text) { + return content.text; + } + + if (content.type === MCPContentType.IMAGE && content.data) { + return createBase64DataUrl(content.mimeType ?? DEFAULT_IMAGE_MIME_TYPE, content.data); + } + + if (content.type === MCPContentType.RESOURCE && content.resource) { + const resource = content.resource; + + if (resource.text) return resource.text; + if (resource.blob) return resource.blob; + + return JSON.stringify(resource); + } + + if (content.data && content.mimeType) { + return createBase64DataUrl(content.mimeType, content.data); + } + + return JSON.stringify(content); + } + + /** + * + * + * Completions Operations + * + * + */ + + /** + * Request completion suggestions from a server. + * Used for autocompleting prompt arguments or resource URI templates. + * + * @param connection - The MCP connection to use + * @param ref - Reference to the prompt or resource template + * @param argument - The argument being completed (name and current value) + * @returns Completion result with suggested values + */ + static async complete( + connection: MCPConnection, + ref: { type: MCPRefType.PROMPT; name: string } | { type: MCPRefType.RESOURCE; uri: string }, + argument: { name: string; value: string } + ): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> { + try { + const result = await connection.client.complete({ + ref, + argument + }); + + return result.completion; + } catch (error) { + console.error(`[MCPService] Failed to get completions:`, error); + + return null; + } + } + + /** + * + * + * Resources Operations + * + * + */ + + /** + * List resources from a connection. + * @param connection - The MCP connection to use + * @param cursor - Optional pagination cursor + * @returns Array of available resources and optional next cursor + */ + static async listResources( + connection: MCPConnection, + cursor?: string + ): Promise<{ resources: MCPResource[]; nextCursor?: string }> { + try { + const result = await connection.client.listResources(cursor ? { cursor } : undefined); + + return { + resources: (result.resources ?? []) as MCPResource[], + nextCursor: result.nextCursor + }; + } catch (error) { + if (this.isSessionExpiredError(error)) { + throw error; + } + + console.warn(`[MCPService][${connection.serverName}] Failed to list resources:`, error); + + return { resources: [] }; + } + } + + /** + * List all resources from a connection (handles pagination automatically). + * @param connection - The MCP connection to use + * @returns Array of all available resources + */ + static async listAllResources(connection: MCPConnection): Promise { + const allResources: MCPResource[] = []; + let cursor: string | undefined; + + do { + const result = await this.listResources(connection, cursor); + allResources.push(...result.resources); + cursor = result.nextCursor; + } while (cursor); + + return allResources; + } + + /** + * List resource templates from a connection. + * @param connection - The MCP connection to use + * @param cursor - Optional pagination cursor + * @returns Array of available resource templates and optional next cursor + */ + static async listResourceTemplates( + connection: MCPConnection, + cursor?: string + ): Promise<{ resourceTemplates: MCPResourceTemplate[]; nextCursor?: string }> { + try { + const result = await connection.client.listResourceTemplates(cursor ? { cursor } : undefined); + + return { + resourceTemplates: (result.resourceTemplates ?? []) as MCPResourceTemplate[], + nextCursor: result.nextCursor + }; + } catch (error) { + if (this.isSessionExpiredError(error)) { + throw error; + } + + console.warn( + `[MCPService][${connection.serverName}] Failed to list resource templates:`, + error + ); + + return { resourceTemplates: [] }; + } + } + + /** + * List all resource templates from a connection (handles pagination automatically). + * @param connection - The MCP connection to use + * @returns Array of all available resource templates + */ + static async listAllResourceTemplates(connection: MCPConnection): Promise { + const allTemplates: MCPResourceTemplate[] = []; + let cursor: string | undefined; + + do { + const result = await this.listResourceTemplates(connection, cursor); + allTemplates.push(...result.resourceTemplates); + cursor = result.nextCursor; + } while (cursor); + + return allTemplates; + } + + /** + * Read the contents of a resource. + * @param connection - The MCP connection to use + * @param uri - The URI of the resource to read + * @returns The resource contents + */ + static async readResource( + connection: MCPConnection, + uri: string + ): Promise { + try { + const result = await connection.client.readResource({ uri }); + + return { + contents: (result.contents ?? []) as MCPResourceContent[], + _meta: result._meta + }; + } catch (error) { + console.error(`[MCPService][${connection.serverName}] Failed to read resource:`, error); + + throw error; + } + } + + /** + * Subscribe to updates for a resource. + * The server will send notifications/resources/updated when the resource changes. + * @param connection - The MCP connection to use + * @param uri - The URI of the resource to subscribe to + */ + static async subscribeResource(connection: MCPConnection, uri: string): Promise { + try { + await connection.client.subscribeResource({ uri }); + + console.log(`[MCPService][${connection.serverName}] Subscribed to resource: ${uri}`); + } catch (error) { + console.error( + `[MCPService][${connection.serverName}] Failed to subscribe to resource:`, + error + ); + + throw error; + } + } + + /** + * Unsubscribe from updates for a resource. + * @param connection - The MCP connection to use + * @param uri - The URI of the resource to unsubscribe from + */ + static async unsubscribeResource(connection: MCPConnection, uri: string): Promise { + try { + await connection.client.unsubscribeResource({ uri }); + + console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`); + } catch (error) { + console.error( + `[MCPService][${connection.serverName}] Failed to unsubscribe from resource:`, + error + ); + + throw error; + } + } + + /** + * Check if a connection supports resources. + * Per MCP spec: presence of the `resources` key (even as empty object `{}`) indicates support. + * Empty object means resources are supported but no sub-features (subscribe, listChanged). + * + * @param connection - The MCP connection to check + * @returns Whether the server declares the resources capability + */ + static supportsResources(connection: MCPConnection): boolean { + // Per MCP spec: "Servers that support resources MUST declare the resources capability" + // The presence of the key indicates support, even if it's an empty object + return connection.serverCapabilities?.resources !== undefined; + } + + /** + * Check if a connection supports resource subscriptions. + * @param connection - The MCP connection to check + * @returns Whether the server supports resource subscriptions + */ + static supportsResourceSubscriptions(connection: MCPConnection): boolean { + return !!connection.serverCapabilities?.resources?.subscribe; + } +} diff --git a/tools/ui/src/lib/services/migration.service.ts b/tools/ui/src/lib/services/migration.service.ts new file mode 100644 index 000000000..5ed24c00d --- /dev/null +++ b/tools/ui/src/lib/services/migration.service.ts @@ -0,0 +1,524 @@ +/** + * Migration Service - Unified data migration hook + * + * Centralizes all data migrations (localStorage, IndexedDB, legacy formats) into a single + * initialization point. Each migration copies data to new format WITHOUT deleting the old. + * + * **Architecture:** + * - Migrations are defined as objects with `id` and `run()` methods + * - Migration state is tracked in localStorage to avoid re-running + * - `runAllMigrations()` should be called once at app startup + * - All migrations are NON-DESTRUCTIVE - legacy data is preserved for downgrade compatibility + * + * **Current Migrations:** + * 1. localStorage prefix: Copy LlamaCppWebui.* → LlamaUi.* (both preserved) + * 2. IndexedDB database: Copy LlamacppWebui → LlamaUi (both preserved) + * 3. Legacy message format: Transform in-place (preserves structure, migrates markers) + * 4. Theme key: Copy standalone `theme` → config object (both preserved) + */ + +import Dexie from 'dexie'; +import { + STORAGE_APP_NAME, + DB_APP_NAME_DEPRECATED, + CONFIG_LOCALSTORAGE_KEY, + IDXDB_TABLES, + IDXDB_STORES, + NEW_TO_DEPRECATED_MAP +} from '$lib/constants'; +import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants/agentic'; +import { SETTINGS_KEYS } from '$lib/constants/settings-registry'; +import { MessageRole } from '$lib/enums'; + +// Types + +interface Migration { + /** Unique identifier for this migration */ + id: string; + /** Human-readable description */ + description: string; + /** Run the migration forward (non-destructive - copies, doesn't delete) */ + run(): Promise; +} + +interface MigrationState { + completed: string[]; + failed: string[]; + lastRun: string; +} + +// Constants + +const MIGRATION_STATE_KEY = `${STORAGE_APP_NAME}.migration-state`; +const MIGRATION_STATE_VERSION = 1; + +// State Management + +function getMigrationState(): MigrationState { + try { + const raw = localStorage.getItem(MIGRATION_STATE_KEY); + if (!raw) return { completed: [], failed: [], lastRun: '' }; + const parsed = JSON.parse(raw); + if (parsed.version !== MIGRATION_STATE_VERSION) { + return { completed: [], failed: [], lastRun: '' }; + } + return { + completed: parsed.completed ?? [], + failed: parsed.failed ?? [], + lastRun: parsed.lastRun ?? '' + }; + } catch { + return { completed: [], failed: [], lastRun: '' }; + } +} + +function saveMigrationState(state: MigrationState): void { + localStorage.setItem( + MIGRATION_STATE_KEY, + JSON.stringify({ + version: MIGRATION_STATE_VERSION, + ...state, + lastRun: new Date().toISOString() + }) + ); +} + +function isMigrationCompleted(id: string): boolean { + const state = getMigrationState(); + return state.completed.includes(id); +} + +function markMigrationCompleted(id: string): void { + const state = getMigrationState(); + if (!state.completed.includes(id)) { + state.completed.push(id); + } + state.failed = state.failed.filter((f) => f !== id); + saveMigrationState(state); +} + +function markMigrationFailed(id: string): void { + const state = getMigrationState(); + if (!state.failed.includes(id)) { + state.failed.push(id); + } + saveMigrationState(state); +} + +// Migration 1: LocalStorage Key Prefix (Non-Destructive) + +const LOCALSTORAGE_MIGRATION_ID = 'localstorage-prefix-v1'; + +const localStorageMigration: Migration = { + id: LOCALSTORAGE_MIGRATION_ID, + description: 'Copy localStorage keys from LlamaCppWebui to LlamaUi prefix (non-destructive)', + + async run(): Promise { + // Non-destructive: copy to new key, but KEEP the old key + for (const [newKey, deprecatedKey] of Object.entries(NEW_TO_DEPRECATED_MAP)) { + // Only migrate if new key doesn't already exist + const newValue = localStorage.getItem(newKey); + if (newValue !== null) { + console.log(`[Migration] localStorage: ${newKey} already exists, skipping`); + continue; + } + + const oldValue = localStorage.getItem(deprecatedKey); + if (oldValue !== null) { + localStorage.setItem(newKey, oldValue); + // Keep old key for downgrade compatibility - DO NOT DELETE + console.log( + `[Migration] localStorage: copied ${deprecatedKey} → ${newKey} (preserved old)` + ); + } + } + } +}; + +// Migration 2: IndexedDB Database Name (Non-Destructive) + +const IDXDB_MIGRATION_ID = 'idxdb-database-v1'; + +const idxdbMigration: Migration = { + id: IDXDB_MIGRATION_ID, + description: 'Copy IndexedDB from LlamacppWebui to LlamaUi database (non-destructive)', + + async run(): Promise { + const oldDbNames = await Dexie.getDatabaseNames(); + if (!oldDbNames.includes(DB_APP_NAME_DEPRECATED)) { + console.log('[Migration] IndexedDB: no old database found, skipping'); + return; + } + + // Check if new database already has data + const newDb = new Dexie(STORAGE_APP_NAME); + newDb.version(1).stores(IDXDB_STORES); + const existingConvs = await newDb.table(IDXDB_TABLES.conversations).count(); + if (existingConvs > 0) { + console.log('[Migration] IndexedDB: new database already has data, skipping'); + return; + } + + console.log('[Migration] IndexedDB: copying from', DB_APP_NAME_DEPRECATED); + + const oldDb = new Dexie(DB_APP_NAME_DEPRECATED); + oldDb.version(1).stores(IDXDB_STORES); + + const conversations = await oldDb.table(IDXDB_TABLES.conversations).toArray(); + const messages = await oldDb.table(IDXDB_TABLES.messages).toArray(); + + if (conversations.length > 0) { + await newDb.table(IDXDB_TABLES.conversations).bulkAdd(conversations); + console.log(`[Migration] IndexedDB: copied ${conversations.length} conversations`); + } + if (messages.length > 0) { + await newDb.table(IDXDB_TABLES.messages).bulkAdd(messages); + console.log(`[Migration] IndexedDB: copied ${messages.length} messages`); + } + + // Non-destructive: DO NOT delete old database - keep for downgrade compatibility + console.log('[Migration] IndexedDB: preserved old database for downgrade compatibility'); + } +}; + +// Migration 3: Legacy Message Format + +const LEGACY_MESSAGE_MIGRATION_ID = 'legacy-message-format-v2'; + +interface ParsedTurn { + textBefore: string; + toolCalls: Array<{ name: string; args: string; result: string }>; +} + +function parseLegacyToolCalls(content: string): ParsedTurn[] { + const turns: ParsedTurn[] = []; + const regex = new RegExp(LEGACY_AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g'); + + let lastIndex = 0; + let currentTurn: ParsedTurn = { textBefore: '', toolCalls: [] }; + let match; + + while ((match = regex.exec(content)) !== null) { + const textBefore = content.slice(lastIndex, match.index).trim(); + + if (textBefore && currentTurn.toolCalls.length > 0) { + turns.push(currentTurn); + currentTurn = { textBefore, toolCalls: [] }; + } else if (textBefore && currentTurn.toolCalls.length === 0) { + currentTurn.textBefore = textBefore; + } + + currentTurn.toolCalls.push({ + name: match[1], + args: match[2], + result: match[3].replace(/^\n+|\n+$/g, '') + }); + + lastIndex = match.index + match[0].length; + } + + const remainingText = content.slice(lastIndex).trim(); + + if (currentTurn.toolCalls.length > 0) { + turns.push(currentTurn); + } + + if (remainingText) { + const cleanRemaining = remainingText + .replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '') + .trim(); + if (cleanRemaining) { + turns.push({ textBefore: cleanRemaining, toolCalls: [] }); + } + } + + if (turns.length === 0) { + turns.push({ textBefore: content.trim(), toolCalls: [] }); + } + + return turns; +} + +function extractLegacyReasoning(content: string): { reasoning: string; cleanContent: string } { + let reasoning = ''; + let cleanContent = content; + + const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g'); + let match; + while ((match = re.exec(content)) !== null) { + reasoning += match[1]; + } + + cleanContent = cleanContent + .replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '') + .replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, ''); + + return { reasoning, cleanContent }; +} + +function hasLegacyMarkers(content: string): boolean { + return LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test(content); +} + +let DatabaseService: typeof import('./database.service').DatabaseService | null = null; + +async function getDatabaseService() { + if (!DatabaseService) { + const module = await import('./database.service'); + DatabaseService = module.DatabaseService; + } + return DatabaseService; +} + +const legacyMessageMigration: Migration = { + id: LEGACY_MESSAGE_MIGRATION_ID, + description: 'Migrate legacy marker-based messages to structured format', + + async run(): Promise { + const db = await getDatabaseService(); + const conversations = await db.getAllConversations(); + let migratedCount = 0; + + for (const conv of conversations) { + const allMessages = await db.getConversationMessages(conv.id); + + for (const message of allMessages) { + if (message.role !== MessageRole.ASSISTANT) { + if (message.content?.includes(LEGACY_REASONING_TAGS.START)) { + const { reasoning, cleanContent } = extractLegacyReasoning(message.content); + await db.updateMessage(message.id, { + content: cleanContent.trim(), + reasoningContent: reasoning || undefined + }); + migratedCount++; + } + continue; + } + + if (!hasLegacyMarkers(message.content ?? '')) continue; + + const { reasoning, cleanContent } = extractLegacyReasoning(message.content); + const turns = parseLegacyToolCalls(cleanContent); + + let existingToolCalls: Array<{ + id: string; + function?: { name: string; arguments: string }; + }> = []; + if (message.toolCalls) { + try { + existingToolCalls = JSON.parse(message.toolCalls); + } catch { + // Ignore + } + } + + const firstTurn = turns[0]; + if (!firstTurn) continue; + + const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => { + const existing = + existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i]; + return { + id: existing?.id || `legacy_tool_${i}`, + type: 'function' as const, + function: { name: tc.name, arguments: tc.args } + }; + }); + + await db.updateMessage(message.id, { + content: firstTurn.textBefore, + reasoningContent: reasoning || undefined, + toolCalls: firstTurnToolCalls.length > 0 ? JSON.stringify(firstTurnToolCalls) : '' + }); + + let currentParentId = message.id; + let toolCallIdCounter = existingToolCalls.length; + + for (let i = 0; i < firstTurn.toolCalls.length; i++) { + const tc = firstTurn.toolCalls[i]; + const toolCallId = firstTurnToolCalls[i]?.id || `legacy_tool_${i}`; + + const toolMsg = await db.createMessageBranch( + { + convId: conv.id, + type: 'text', + role: MessageRole.TOOL, + content: tc.result, + toolCallId, + timestamp: message.timestamp + i + 1, + toolCalls: '', + children: [] + }, + currentParentId + ); + currentParentId = toolMsg.id; + } + + for (let turnIdx = 1; turnIdx < turns.length; turnIdx++) { + const turn = turns[turnIdx]; + + const turnToolCalls = turn.toolCalls.map((tc, i) => { + const idx = toolCallIdCounter + i; + const existing = existingToolCalls[idx]; + return { + id: existing?.id || `legacy_tool_${idx}`, + type: 'function' as const, + function: { name: tc.name, arguments: tc.args } + }; + }); + toolCallIdCounter += turn.toolCalls.length; + + const assistantMsg = await db.createMessageBranch( + { + convId: conv.id, + type: 'text', + role: MessageRole.ASSISTANT, + content: turn.textBefore, + timestamp: message.timestamp + turnIdx * 100, + toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '', + children: [], + model: message.model + }, + currentParentId + ); + currentParentId = assistantMsg.id; + + for (let i = 0; i < turn.toolCalls.length; i++) { + const tc = turn.toolCalls[i]; + const toolCallId = turnToolCalls[i]?.id || `legacy_tool_${toolCallIdCounter + i}`; + + const toolMsg = await db.createMessageBranch( + { + convId: conv.id, + type: 'text', + role: MessageRole.TOOL, + content: tc.result, + toolCallId, + timestamp: message.timestamp + turnIdx * 100 + i + 1, + toolCalls: '', + children: [] + }, + currentParentId + ); + currentParentId = toolMsg.id; + } + } + + if (message.children.length > 0 && currentParentId !== message.id) { + for (const childId of message.children) { + const child = allMessages.find((m) => m.id === childId); + if (!child) continue; + if (child.role !== MessageRole.TOOL) { + await db.updateMessage(childId, { parent: currentParentId }); + } + } + await db.updateMessage(message.id, { children: [] }); + } + + migratedCount++; + } + } + + console.log(`[Migration] Legacy messages: migrated ${migratedCount} messages`); + } +}; + +// Migration 4: Theme Key (Non-Destructive) + +const THEME_MIGRATION_ID = 'theme-key-v1'; + +const themeMigration: Migration = { + id: THEME_MIGRATION_ID, + description: 'Copy standalone theme key to config object (non-destructive)', + + async run(): Promise { + const legacyTheme = localStorage.getItem('theme'); + if (legacyTheme === null) { + console.log('[Migration] Theme: no legacy theme key found, skipping'); + return; + } + + // Check if config already has theme + const configRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY); + const config = configRaw ? JSON.parse(configRaw) : {}; + + if (SETTINGS_KEYS.THEME in config) { + console.log('[Migration] Theme: config already has theme, skipping'); + return; + } + + config[SETTINGS_KEYS.THEME] = legacyTheme; + localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config)); + + // Non-destructive: DO NOT delete legacy theme key - keep for downgrade compatibility + console.log(`[Migration] Theme: copied standalone theme to config (preserved old key)`); + } +}; + +// Migration Registry & Runner + +const migrations: Migration[] = [ + localStorageMigration, + idxdbMigration, + legacyMessageMigration, + themeMigration +]; + +export const MigrationService = { + /** + * Get all registered migrations + */ + getMigrations(): Migration[] { + return [...migrations]; + }, + + /** + * Check if a specific migration has been completed + */ + isCompleted(id: string): boolean { + return isMigrationCompleted(id); + }, + + /** + * Get current migration state + */ + getState(): MigrationState { + return getMigrationState(); + }, + + /** + * Reset migration state (use with caution - migrations will run again) + */ + resetState(): void { + localStorage.removeItem(MIGRATION_STATE_KEY); + console.log('[Migration] State reset - all migrations will run again'); + }, + + /** + * Run all pending migrations (non-destructive - preserves legacy data) + * Should be called once at app initialization + */ + async runAllMigrations(): Promise { + const state = getMigrationState(); + console.log('[Migration] Starting migration run, state:', state); + + for (const migration of migrations) { + if (isMigrationCompleted(migration.id)) { + console.log(`[Migration] ${migration.id}: already completed, skipping`); + continue; + } + + try { + console.log(`[Migration] ${migration.id}: running...`); + await migration.run(); + markMigrationCompleted(migration.id); + console.log(`[Migration] ${migration.id}: completed successfully`); + } catch (error) { + console.error(`[Migration] ${migration.id}: failed`, error); + markMigrationFailed(migration.id); + } + } + + console.log('[Migration] All migrations complete'); + } +}; diff --git a/tools/ui/src/lib/services/models.service.ts b/tools/ui/src/lib/services/models.service.ts new file mode 100644 index 000000000..209bd7cab --- /dev/null +++ b/tools/ui/src/lib/services/models.service.ts @@ -0,0 +1,228 @@ +import { ServerModelStatus } from '$lib/enums'; +import { apiFetch, apiPost } from '$lib/utils'; +import type { ParsedModelId } from '$lib/types/models'; +import { + MODEL_QUANTIZATION_SEGMENT_RE, + MODEL_CUSTOM_QUANTIZATION_PREFIX_RE, + MODEL_PARAMS_RE, + MODEL_ACTIVATED_PARAMS_RE, + MODEL_IGNORED_SEGMENTS, + MODEL_ID_NOT_FOUND, + MODEL_ID_ORG_SEPARATOR, + MODEL_ID_SEGMENT_SEPARATOR, + MODEL_ID_QUANTIZATION_SEPARATOR, + API_MODELS +} from '$lib/constants'; + +export class ModelsService { + /** + * + * + * Listing + * + * + */ + + /** + * Fetch list of models from OpenAI-compatible endpoint. + * Works in both MODEL and ROUTER modes. + * + * @returns List of available models with basic metadata + */ + static async list(): Promise { + return apiFetch(API_MODELS.LIST); + } + + /** + * Fetch list of all models with detailed metadata (ROUTER mode). + * Returns models with load status, paths, and other metadata + * beyond what the OpenAI-compatible endpoint provides. + * + * @returns List of models with detailed status and configuration info + */ + static async listRouter(): Promise { + return apiFetch(API_MODELS.LIST); + } + + /** + * + * + * Load/Unload + * + * + */ + + /** + * Load a model (ROUTER mode only). + * Sends POST request to `/models/load`. Note: the endpoint returns success + * before loading completes — use polling to await actual load status. + * + * @param modelId - Model identifier to load + * @param extraArgs - Optional additional arguments to pass to the model instance + * @returns Load response from the server + */ + static async load(modelId: string, extraArgs?: string[]): Promise { + const payload: { model: string; extra_args?: string[] } = { model: modelId }; + if (extraArgs && extraArgs.length > 0) { + payload.extra_args = extraArgs; + } + + return apiPost(API_MODELS.LOAD, payload); + } + + /** + * Unload a model (ROUTER mode only). + * Sends POST request to `/models/unload`. Note: the endpoint returns success + * before unloading completes — use polling to await actual unload status. + * + * @param modelId - Model identifier to unload + * @returns Unload response from the server + */ + static async unload(modelId: string): Promise { + return apiPost(API_MODELS.UNLOAD, { model: modelId }); + } + + /** + * + * + * Status + * + * + */ + + /** + * Check if a model is loaded based on its metadata. + * + * @param model - Model data entry from the API response + * @returns True if the model status is LOADED + */ + static isModelLoaded(model: ApiModelDataEntry): boolean { + return model.status.value === ServerModelStatus.LOADED; + } + + /** + * Check if a model is currently loading. + * + * @param model - Model data entry from the API response + * @returns True if the model status is LOADING + */ + static isModelLoading(model: ApiModelDataEntry): boolean { + return model.status.value === ServerModelStatus.LOADING; + } + + /** + * + * + * Parsing + * + * + */ + + /** + * Parse a model ID string into its structured components. + * + * Handles conventions like: + * `/-(-)(-)(-):` + * `.` (dot-separated quantization, e.g. `model.Q4_K_M`) + * + * @param modelId - Raw model identifier string + * @returns Structured {@link ParsedModelId} with all detected fields + */ + static parseModelId(modelId: string): ParsedModelId { + const result: ParsedModelId = { + raw: modelId, + orgName: null, + modelName: null, + params: null, + activatedParams: null, + quantization: null, + tags: [] + }; + + // 1. Extract colon-separated quantization (e.g. `model:Q4_K_M`) + const colonIdx = modelId.indexOf(MODEL_ID_QUANTIZATION_SEPARATOR); + let modelPath: string; + + if (colonIdx !== MODEL_ID_NOT_FOUND) { + result.quantization = modelId.slice(colonIdx + 1) || null; + modelPath = modelId.slice(0, colonIdx); + } else { + modelPath = modelId; + } + + // 2. Extract org name (e.g. `org/model` -> org = "org") + const slashIdx = modelPath.indexOf(MODEL_ID_ORG_SEPARATOR); + let modelStr: string; + + if (slashIdx !== MODEL_ID_NOT_FOUND) { + result.orgName = modelPath.slice(0, slashIdx); + modelStr = modelPath.slice(slashIdx + 1); + } else { + modelStr = modelPath; + } + + // 3. Handle dot-separated quantization (e.g. `model-name.Q4_K_M`) + const dotIdx = modelStr.lastIndexOf('.'); + + if (dotIdx !== MODEL_ID_NOT_FOUND && !result.quantization) { + const afterDot = modelStr.slice(dotIdx + 1); + + if (MODEL_QUANTIZATION_SEGMENT_RE.test(afterDot)) { + result.quantization = afterDot; + modelStr = modelStr.slice(0, dotIdx); + } + } + + const segments = modelStr.split(MODEL_ID_SEGMENT_SEPARATOR); + + // 4. Detect trailing quantization from dash-separated segments + // Handle UD-prefixed quantization (e.g. `UD-Q8_K_XL`) and + // standalone quantization (e.g. `Q4_K_M`, `BF16`, `F16`, `MXFP4`) + if (!result.quantization && segments.length > 1) { + const last = segments[segments.length - 1]; + const secondLast = segments.length > 2 ? segments[segments.length - 2] : null; + + if (MODEL_QUANTIZATION_SEGMENT_RE.test(last)) { + if (secondLast && MODEL_CUSTOM_QUANTIZATION_PREFIX_RE.test(secondLast)) { + result.quantization = `${secondLast}-${last}`; + segments.splice(segments.length - 2, 2); + } else { + result.quantization = last; + segments.pop(); + } + } + } + + // 5. Find params and activated params + let paramsIdx = MODEL_ID_NOT_FOUND; + let activatedParamsIdx = MODEL_ID_NOT_FOUND; + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + + if (paramsIdx === MODEL_ID_NOT_FOUND && MODEL_PARAMS_RE.test(seg)) { + paramsIdx = i; + result.params = seg.toUpperCase(); + } else if (paramsIdx !== MODEL_ID_NOT_FOUND && MODEL_ACTIVATED_PARAMS_RE.test(seg)) { + activatedParamsIdx = i; + result.activatedParams = seg.toUpperCase(); + } + } + + // 6. Model name = segments before params; tags = remaining segments after params + const pivotIdx = paramsIdx !== MODEL_ID_NOT_FOUND ? paramsIdx : segments.length; + + result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID_SEGMENT_SEPARATOR) || null; + + if (paramsIdx !== MODEL_ID_NOT_FOUND) { + result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => { + const absIdx = paramsIdx + 1 + relIdx; + if (absIdx === activatedParamsIdx) return false; + + return !MODEL_IGNORED_SEGMENTS.has(segments[absIdx].toUpperCase()); + }); + } + + return result; + } +} diff --git a/tools/ui/src/lib/services/parameter-sync.service.spec.ts b/tools/ui/src/lib/services/parameter-sync.service.spec.ts new file mode 100644 index 000000000..f15c0c143 --- /dev/null +++ b/tools/ui/src/lib/services/parameter-sync.service.spec.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest'; +import { ParameterSyncService } from './parameter-sync.service'; +import { ColorMode } from '$lib/enums'; + +describe('ParameterSyncService', () => { + describe('roundFloatingPoint', () => { + it('should fix JavaScript floating-point precision issues', () => { + // Test the specific values from the screenshot + const mockServerParams = { + top_p: 0.949999988079071, + min_p: 0.009999999776482582, + temperature: 0.800000011920929, + top_k: 40, + samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature'] + }; + + const result = ParameterSyncService.extractServerDefaults({ + ...mockServerParams, + // Add other required fields to match the API type + n_predict: 512, + seed: -1, + dynatemp_range: 0.0, + dynatemp_exponent: 1.0, + xtc_probability: 0.0, + xtc_threshold: 0.1, + typ_p: 1.0, + repeat_last_n: 64, + repeat_penalty: 1.0, + presence_penalty: 0.0, + frequency_penalty: 0.0, + dry_multiplier: 0.0, + dry_base: 1.75, + dry_allowed_length: 2, + dry_penalty_last_n: -1, + mirostat: 0, + mirostat_tau: 5.0, + mirostat_eta: 0.1, + stop: [], + max_tokens: -1, + n_keep: 0, + n_discard: 0, + ignore_eos: false, + stream: true, + logit_bias: [], + n_probs: 0, + min_keep: 0, + grammar: '', + grammar_lazy: false, + grammar_triggers: [], + preserved_tokens: [], + chat_format: '', + reasoning_format: '', + reasoning_in_content: false, + generation_prompt: '', + 'speculative.n_max': 0, + 'speculative.n_min': 0, + 'speculative.p_min': 0.0, + timings_per_token: false, + post_sampling_probs: false, + lora: [], + top_n_sigma: 0.0, + dry_sequence_breakers: [] + } as ApiLlamaCppServerProps['default_generation_settings']['params']); + + // Check that the problematic floating-point values are rounded correctly + expect(result.top_p).toBe(0.95); + expect(result.min_p).toBe(0.01); + expect(result.temperature).toBe(0.8); + expect(result.top_k).toBe(40); // Integer should remain unchanged + expect(result.samplers).toBe('top_k;typ_p;top_p;min_p;temperature'); + }); + + it('should preserve non-numeric values', () => { + const mockServerParams = { + samplers: ['top_k', 'temperature'], + max_tokens: -1, + temperature: 0.7 + }; + + const result = ParameterSyncService.extractServerDefaults({ + ...mockServerParams, + // Minimal required fields + n_predict: 512, + seed: -1, + dynatemp_range: 0.0, + dynatemp_exponent: 1.0, + top_k: 40, + top_p: 0.95, + min_p: 0.05, + xtc_probability: 0.0, + xtc_threshold: 0.1, + typ_p: 1.0, + repeat_last_n: 64, + repeat_penalty: 1.0, + presence_penalty: 0.0, + frequency_penalty: 0.0, + dry_multiplier: 0.0, + dry_base: 1.75, + dry_allowed_length: 2, + dry_penalty_last_n: -1, + mirostat: 0, + mirostat_tau: 5.0, + mirostat_eta: 0.1, + stop: [], + n_keep: 0, + n_discard: 0, + ignore_eos: false, + stream: true, + logit_bias: [], + n_probs: 0, + min_keep: 0, + grammar: '', + grammar_lazy: false, + grammar_triggers: [], + preserved_tokens: [], + chat_format: '', + reasoning_format: '', + reasoning_in_content: false, + generation_prompt: '', + 'speculative.n_max': 0, + 'speculative.n_min': 0, + 'speculative.p_min': 0.0, + timings_per_token: false, + post_sampling_probs: false, + lora: [], + top_n_sigma: 0.0, + dry_sequence_breakers: [] + } as ApiLlamaCppServerProps['default_generation_settings']['params']); + + expect(result.samplers).toBe('top_k;temperature'); + expect(result.max_tokens).toBe(-1); + expect(result.temperature).toBe(0.7); + }); + + it('should merge ui settings from props when provided', () => { + const result = ParameterSyncService.extractServerDefaults(null, { + pasteLongTextToFileLen: 0, + pdfAsImage: true, + renderUserContentAsMarkdown: false, + theme: ColorMode.DARK + }); + + expect(result.pasteLongTextToFileLen).toBe(0); + expect(result.pdfAsImage).toBe(true); + expect(result.renderUserContentAsMarkdown).toBe(false); + expect(result.theme).toBeUndefined(); + }); + }); +}); diff --git a/tools/ui/src/lib/services/parameter-sync.service.ts b/tools/ui/src/lib/services/parameter-sync.service.ts new file mode 100644 index 000000000..900471b3a --- /dev/null +++ b/tools/ui/src/lib/services/parameter-sync.service.ts @@ -0,0 +1,232 @@ +import { normalizeFloatingPoint } from '$lib/utils'; +import { SETTINGS_KEYS, SYNCABLE_PARAMETERS } from '$lib/constants'; +import type { ParameterRecord, ParameterInfo, ParameterValue } from '$lib/types'; +import { SyncableParameterType, ParameterSource } from '$lib/enums'; + +export class ParameterSyncService { + /** + * + * + * Extraction + * + * + */ + + /** + * Round floating-point numbers to avoid JavaScript precision issues. + * E.g., 0.1 + 0.2 = 0.30000000000000004 → 0.3 + * + * @param value - Parameter value to normalize + * @returns Precision-normalized value + */ + private static roundFloatingPoint(value: ParameterValue): ParameterValue { + return normalizeFloatingPoint(value) as ParameterValue; + } + + /** + * Extract server default parameters that can be synced from `/props` response. + * Handles both generation settings parameters and UI-specific settings. + * Converts samplers array to semicolon-delimited string for UI display. + * + * @param serverParams - Raw generation settings from server `/props` endpoint + * @param uiSettings - Optional UI-specific settings from server + * @returns Record of extracted parameter key-value pairs with normalized precision + */ + static extractServerDefaults( + serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null, + uiSettings?: Record + ): ParameterRecord { + const extracted: ParameterRecord = {}; + + if (serverParams) { + for (const param of SYNCABLE_PARAMETERS) { + if (param.canSync && param.serverKey in serverParams) { + const value = (serverParams as unknown as Record)[ + param.serverKey + ]; + if (value !== undefined) { + // Apply precision rounding to avoid JavaScript floating-point issues + extracted[param.key] = this.roundFloatingPoint(value); + } + } + } + + // Handle samplers array conversion to string + if (serverParams.samplers && Array.isArray(serverParams.samplers)) { + extracted[SETTINGS_KEYS.SAMPLERS] = serverParams.samplers.join(';'); + } + } + + if (uiSettings) { + for (const param of SYNCABLE_PARAMETERS) { + if (param.canSync && param.serverKey in uiSettings) { + const value = uiSettings[param.serverKey]; + + if (value !== undefined) { + extracted[param.key] = this.roundFloatingPoint(value); + } + } + } + } + + return extracted; + } + + /** + * + * + * Merging + * + * + */ + + /** + * Merge server defaults with current user settings. + * User overrides always take priority — only parameters not in `userOverrides` + * set will be updated from server defaults. + * + * @param currentSettings - Current parameter values in the settings store + * @param serverDefaults - Default values extracted from server props + * @param userOverrides - Set of parameter keys explicitly overridden by the user + * @returns Merged parameter record with user overrides preserved + */ + static mergeWithServerDefaults( + currentSettings: ParameterRecord, + serverDefaults: ParameterRecord, + userOverrides: Set = new Set() + ): ParameterRecord { + const merged = { ...currentSettings }; + + for (const [key, serverValue] of Object.entries(serverDefaults)) { + // Only update if user hasn't explicitly overridden this parameter + if (!userOverrides.has(key)) { + merged[key] = this.roundFloatingPoint(serverValue); + } + } + + return merged; + } + + /** + * + * + * Info + * + * + */ + + /** + * Get parameter information including source and values. + * Used by SettingsChatParameterSourceIndicator to display the correct badge + * (Custom vs Default) for each parameter in the settings UI. + * + * @param key - The parameter key to get info for + * @param currentValue - The current value of the parameter + * @param propsDefaults - Server default values from `/props` + * @param userOverrides - Set of parameter keys explicitly overridden by the user + * @returns Parameter info with source, server default, and user override values + */ + static getParameterInfo( + key: string, + currentValue: ParameterValue, + propsDefaults: ParameterRecord, + userOverrides: Set + ): ParameterInfo { + const hasPropsDefault = propsDefaults[key] !== undefined; + const isUserOverride = userOverrides.has(key); + + // Simple logic: either using default (from props) or custom (user override) + const source = isUserOverride ? ParameterSource.CUSTOM : ParameterSource.DEFAULT; + + return { + value: currentValue, + source, + serverDefault: hasPropsDefault ? propsDefaults[key] : undefined, // Keep same field name for compatibility + userOverride: isUserOverride ? currentValue : undefined + }; + } + + /** + * Check if a parameter can be synced from server. + * + * @param key - The parameter key to check + * @returns True if the parameter is in the syncable parameters list + */ + static canSyncParameter(key: string): boolean { + return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync); + } + + /** + * Get all syncable parameter keys. + * + * @returns Array of parameter keys that can be synced from server + */ + static getSyncableParameterKeys(): string[] { + return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key); + } + + /** + * Validate a server parameter value against its expected type. + * + * @param key - The parameter key to validate + * @param value - The value to validate + * @returns True if value matches the expected type for this parameter + */ + static validateServerParameter(key: string, value: ParameterValue): boolean { + const param = SYNCABLE_PARAMETERS.find((p) => p.key === key); + if (!param) return false; + + switch (param.type) { + case SyncableParameterType.NUMBER: + return typeof value === 'number' && !isNaN(value); + case SyncableParameterType.STRING: + return typeof value === 'string'; + case SyncableParameterType.BOOLEAN: + return typeof value === 'boolean'; + default: + return false; + } + } + + /** + * + * + * Diff + * + * + */ + + /** + * Create a diff between current settings and server defaults. + * Shows which parameters differ from server values, useful for debugging + * and for the "Reset to defaults" functionality. + * + * @param currentSettings - Current parameter values in the settings store + * @param serverDefaults - Default values extracted from server props + * @returns Record of parameter diffs with current value, server value, and whether they differ + */ + static createParameterDiff( + currentSettings: ParameterRecord, + serverDefaults: ParameterRecord + ): Record { + const diff: Record< + string, + { current: ParameterValue; server: ParameterValue; differs: boolean } + > = {}; + + for (const key of this.getSyncableParameterKeys()) { + const currentValue = currentSettings[key]; + const serverValue = serverDefaults[key]; + + if (serverValue !== undefined) { + diff[key] = { + current: currentValue, + server: serverValue, + differs: currentValue !== serverValue + }; + } + } + + return diff; + } +} diff --git a/tools/ui/src/lib/services/props.service.ts b/tools/ui/src/lib/services/props.service.ts new file mode 100644 index 000000000..45c3e4577 --- /dev/null +++ b/tools/ui/src/lib/services/props.service.ts @@ -0,0 +1,47 @@ +import { apiFetchWithParams } from '$lib/utils'; + +export class PropsService { + /** + * + * + * Fetching + * + * + */ + + /** + * Fetches global server properties from the `/props` endpoint. + * In MODEL mode, returns modalities for the single loaded model. + * In ROUTER mode, returns server-wide settings without model-specific modalities. + * + * @param autoload - If false, prevents automatic model loading (default: false) + * @returns Server properties including default generation settings and capabilities + * @throws {Error} If the request fails or returns invalid data + */ + static async fetch(autoload = false): Promise { + const params: Record = {}; + if (!autoload) { + params.autoload = 'false'; + } + + return apiFetchWithParams('./props', params, { authOnly: true }); + } + + /** + * Fetches server properties for a specific model (ROUTER mode only). + * Required in ROUTER mode because global `/props` does not include per-model modalities. + * + * @param modelId - The model ID to fetch properties for + * @param autoload - If false, prevents automatic model loading (default: false) + * @returns Server properties specific to the requested model + * @throws {Error} If the request fails, model not found, or model not loaded + */ + static async fetchForModel(modelId: string, autoload = false): Promise { + const params: Record = { model: modelId }; + if (!autoload) { + params.autoload = 'false'; + } + + return apiFetchWithParams('./props', params, { authOnly: true }); + } +} diff --git a/tools/ui/src/lib/services/router.service.ts b/tools/ui/src/lib/services/router.service.ts new file mode 100644 index 000000000..6fa172eec --- /dev/null +++ b/tools/ui/src/lib/services/router.service.ts @@ -0,0 +1,11 @@ +import { ROUTES } from '$lib/constants/routes'; + +export class RouterService { + static chat(id: string): string { + return `${ROUTES.CHAT}/${id}`; + } + + static settings(section: string): string { + return `${ROUTES.SETTINGS}/${section}`; + } +} diff --git a/tools/ui/src/lib/services/tools.service.ts b/tools/ui/src/lib/services/tools.service.ts new file mode 100644 index 000000000..8f39f5209 --- /dev/null +++ b/tools/ui/src/lib/services/tools.service.ts @@ -0,0 +1,40 @@ +import { apiFetch } from '$lib/utils'; +import { API_TOOLS } from '$lib/constants'; +import { ToolResponseField } from '$lib/enums'; +import type { ToolExecutionResult, ServerBuiltinToolInfo } from '$lib/types'; + +export class ToolsService { + /** + * Fetch the list of built-in tools from the server. + * + * @returns Array of tool definitions in OpenAI-compatible format + */ + static async list(): Promise { + return apiFetch(API_TOOLS.LIST); + } + + /** + * Execute a built-in tool on the server. + */ + static async executeTool( + toolName: string, + params: Record, + signal?: AbortSignal + ): Promise { + const result = await apiFetch>(API_TOOLS.EXECUTE, { + method: 'POST', + body: JSON.stringify({ tool: toolName, params }), + signal + }); + + if (ToolResponseField.ERROR in result) { + return { content: String(result[ToolResponseField.ERROR]), isError: true }; + } + + if (ToolResponseField.PLAIN_TEXT in result) { + return { content: String(result[ToolResponseField.PLAIN_TEXT]), isError: false }; + } + + return { content: JSON.stringify(result), isError: false }; + } +} diff --git a/tools/ui/src/lib/stores/agentic.svelte.ts b/tools/ui/src/lib/stores/agentic.svelte.ts new file mode 100644 index 000000000..e8c0cc523 --- /dev/null +++ b/tools/ui/src/lib/stores/agentic.svelte.ts @@ -0,0 +1,1025 @@ +/** + * agenticStore - Reactive State Store for Agentic Loop Orchestration + * + * Manages multi-turn agentic loop with MCP tools: + * - LLM streaming with tool call detection + * - Tool execution via mcpStore + * - Session state management + * - Turn limit enforcement + * + * Each agentic turn produces separate DB messages: + * - One assistant message per LLM turn (with tool_calls if any) + * - One tool result message per tool call execution + * + * **Architecture & Relationships:** + * - **ChatService**: Stateless API layer (sendMessage, streaming) + * - **mcpStore**: MCP connection management and tool execution + * - **agenticStore** (this): Reactive state + business logic + * + * @see ChatService in services/chat.service.ts for API operations + * @see mcpStore in stores/mcp.svelte.ts for MCP operations + */ + +import { ChatService } from '$lib/services'; +import { config } from '$lib/stores/settings.svelte'; +import { mcpStore } from '$lib/stores/mcp.svelte'; +import { modelsStore } from '$lib/stores/models.svelte'; +import { toolsStore } from '$lib/stores/tools.svelte'; +import { permissionsStore } from '$lib/stores/permissions.svelte'; +import { ToolSource, ToolPermissionDecision } from '$lib/enums'; +import { SvelteMap } from 'svelte/reactivity'; +import { ToolsService } from '$lib/services/tools.service'; +import { isAbortError } from '$lib/utils'; +import { DEFAULT_AGENTIC_CONFIG, NEWLINE_SEPARATOR } from '$lib/constants'; +import { + IMAGE_MIME_TO_EXTENSION, + DATA_URI_BASE64_REGEX, + MCP_ATTACHMENT_NAME_PREFIX, + DEFAULT_IMAGE_EXTENSION +} from '$lib/constants'; +import { + AttachmentType, + ContentPartType, + MessageRole, + MimeTypePrefix, + ToolCallType +} from '$lib/enums'; +import type { + AgenticFlowParams, + AgenticFlowResult, + AgenticSession, + AgenticConfig, + SettingsConfigType, + McpServerOverride, + MCPToolCall +} from '$lib/types'; +import type { + AgenticMessage, + AgenticToolCallList, + AgenticFlowCallbacks, + AgenticFlowOptions, + SteeringMessage +} from '$lib/types/agentic'; +import type { + ApiChatCompletionToolCall, + ApiChatMessageData, + ApiChatMessageContentPart +} from '$lib/types/api'; +import type { + ChatMessagePromptProgress, + ChatMessageTimings, + ChatMessageAgenticTimings, + ChatMessageToolCallTiming, + ChatMessageAgenticTurnStats +} from '$lib/types/chat'; +import type { + DatabaseMessage, + DatabaseMessageExtra, + DatabaseMessageExtraImageFile +} from '$lib/types/database'; + +function createDefaultSession(): AgenticSession { + return { + isRunning: false, + currentTurn: 0, + totalToolCalls: 0, + lastError: null, + streamingToolCall: null, + pendingPermissionRequest: null + }; +} + +function toAgenticMessages(messages: ApiChatMessageData[]): AgenticMessage[] { + return messages.map((message) => { + if ( + message.role === MessageRole.ASSISTANT && + message.tool_calls && + message.tool_calls.length > 0 + ) { + return { + role: MessageRole.ASSISTANT, + content: message.content, + reasoning_content: message.reasoning_content, + tool_calls: message.tool_calls.map((call, index) => ({ + id: call.id ?? `call_${index}`, + type: (call.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION, + function: { + name: call.function?.name ?? '', + arguments: call.function?.arguments ?? '' + } + })) + } satisfies AgenticMessage; + } + if (message.role === MessageRole.ASSISTANT) { + return { + role: MessageRole.ASSISTANT, + content: message.content, + reasoning_content: message.reasoning_content + } satisfies AgenticMessage; + } + if (message.role === MessageRole.TOOL && message.tool_call_id) { + return { + role: MessageRole.TOOL, + tool_call_id: message.tool_call_id, + content: typeof message.content === 'string' ? message.content : '' + } satisfies AgenticMessage; + } + return { + role: message.role as MessageRole.SYSTEM | MessageRole.USER, + content: message.content + } satisfies AgenticMessage; + }); +} + +class AgenticStore { + private _sessions = new SvelteMap(); + /** Dedicated reactive state for pending permission requests (ensures immediate UI updates) */ + private _pendingPermissions = new SvelteMap< + string, + { toolName: string; serverLabel: string } | null + >(); + /** Non-reactive: stores resolve functions for pending permission Promises */ + private _permissionResolvers = new Map void>(); + + /** Dedicated reactive state for pending continue requests (turn limit reached) */ + private _pendingContinueRequests = new SvelteMap(); + /** Non-reactive: stores resolve functions for pending continue Promises */ + private _continueResolvers = new Map void>(); + + /** Reactive: queued steering messages to inject between turns */ + private _steeringMessages = new SvelteMap(); + + get isReady(): boolean { + return true; + } + get isAnyRunning(): boolean { + for (const session of this._sessions.values()) { + if (session.isRunning) return true; + } + return false; + } + + getSession(conversationId: string): AgenticSession { + let session = this._sessions.get(conversationId); + if (!session) { + session = createDefaultSession(); + this._sessions.set(conversationId, session); + } + return session; + } + + private updateSession(conversationId: string, update: Partial): void { + const session = this.getSession(conversationId); + this._sessions.set(conversationId, { ...session, ...update }); + } + + clearSession(conversationId: string): void { + this._sessions.delete(conversationId); + } + + getActiveSessions(): Array<{ conversationId: string; session: AgenticSession }> { + const active: Array<{ conversationId: string; session: AgenticSession }> = []; + for (const [conversationId, session] of this._sessions.entries()) { + if (session.isRunning) active.push({ conversationId, session }); + } + return active; + } + + isRunning(conversationId: string): boolean { + return this.getSession(conversationId).isRunning; + } + + currentTurn(conversationId: string): number { + return this.getSession(conversationId).currentTurn; + } + + totalToolCalls(conversationId: string): number { + return this.getSession(conversationId).totalToolCalls; + } + + lastError(conversationId: string): Error | null { + return this.getSession(conversationId).lastError; + } + + streamingToolCall(conversationId: string): { name: string; arguments: string } | null { + return this.getSession(conversationId).streamingToolCall; + } + + pendingPermissionRequest( + conversationId: string + ): { toolName: string; serverLabel: string } | null { + return this._pendingPermissions.get(conversationId) ?? null; + } + + pendingContinueRequest(conversationId: string): boolean { + return this._pendingContinueRequests.get(conversationId) ?? false; + } + + resolveContinue(conversationId: string, shouldContinue: boolean): void { + const resolver = this._continueResolvers.get(conversationId); + if (resolver) { + this._continueResolvers.delete(conversationId); + resolver(shouldContinue); + } + } + + resolvePermission(conversationId: string, decision: ToolPermissionDecision): void { + const resolver = this._permissionResolvers.get(conversationId); + if (resolver) { + this._permissionResolvers.delete(conversationId); + resolver(decision); + } + } + + clearError(conversationId: string): void { + this.updateSession(conversationId, { lastError: null }); + } + + hasPendingSteeringMessage(conversationId: string): boolean { + return this._steeringMessages.has(conversationId); + } + + pendingSteeringMessageContent(conversationId: string): string | null { + return this._steeringMessages.get(conversationId)?.content ?? null; + } + + pendingSteeringMessageExtras(conversationId: string): DatabaseMessageExtra[] | undefined { + return this._steeringMessages.get(conversationId)?.extras; + } + + /** + * Queue a steering message. When the current agentic turn completes, + * the flow exits and the caller re-sends the message as a normal chat message. + */ + injectSteeringMessage( + conversationId: string, + content: string, + extras?: DatabaseMessageExtra[] + ): void { + this._steeringMessages.set(conversationId, { content, extras }); + } + + /** + * Clear the pending steering message without consuming it. + */ + clearSteeringMessage(conversationId: string): void { + this._steeringMessages.delete(conversationId); + } + + /** + * Consume and return the pending steering message for re-sending. + * Called by chatStore after the agentic flow exits. + */ + consumePendingSteeringMessage(conversationId: string): SteeringMessage | null { + const msg = this._steeringMessages.get(conversationId); + if (!msg) return null; + this._steeringMessages.delete(conversationId); + return msg; + } + + getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig { + const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns; + const maxToolPreviewLines = + Number(settings.agenticMaxToolPreviewLines) || DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines; + const hasTools = + mcpStore.hasEnabledServers(perChatOverrides) || + toolsStore.builtinTools.length > 0 || + toolsStore.customTools.length > 0; + return { + enabled: hasTools && DEFAULT_AGENTIC_CONFIG.enabled, + maxTurns, + maxToolPreviewLines + }; + } + + private parseToolArguments(args: string | Record): Record { + if (typeof args === 'object') return args; + const trimmed = args.trim(); + if (trimmed === '') return {}; + return JSON.parse(trimmed) as Record; + } + + private async requestPermission( + conversationId: string, + toolName: string, + serverLabel: string, + signal?: AbortSignal + ): Promise { + const permissionKey = toolsStore.getPermissionKey(toolName); + if (permissionKey && permissionsStore.hasTool(permissionKey)) { + return ToolPermissionDecision.ONCE; + } + + this._pendingPermissions.set(conversationId, { toolName, serverLabel }); + + return new Promise((resolve) => { + if (signal?.aborted) { + this._pendingPermissions.set(conversationId, null); + resolve(ToolPermissionDecision.DENY); + return; + } + + this._permissionResolvers.set(conversationId, (decision) => { + this._pendingPermissions.set(conversationId, null); + if (decision === ToolPermissionDecision.ALWAYS && permissionKey) { + permissionsStore.allowTool(permissionKey); + } else if (decision === ToolPermissionDecision.ALWAYS_SERVER) { + const serverToolKeys = toolsStore.allTools + .filter((t) => + t.serverName + ? t.serverName === serverLabel + : toolsStore.getToolServerLabel(t.definition.function.name) === serverLabel + ) + .map((t) => toolsStore.getPermissionKey(t.definition.function.name)!) + .filter((k): k is string => k !== null); + permissionsStore.allowTools(serverToolKeys); + } + resolve(decision); + }); + + signal?.addEventListener( + 'abort', + () => { + const resolver = this._permissionResolvers.get(conversationId); + if (resolver) { + this._permissionResolvers.delete(conversationId); + this._pendingPermissions.set(conversationId, null); + resolve(ToolPermissionDecision.DENY); + } + }, + { once: true } + ); + }); + } + + private async requestContinue(conversationId: string, signal?: AbortSignal): Promise { + this._pendingContinueRequests.set(conversationId, true); + + return new Promise((resolve) => { + if (signal?.aborted) { + this._pendingContinueRequests.set(conversationId, false); + resolve(false); + return; + } + + this._continueResolvers.set(conversationId, (shouldContinue) => { + this._pendingContinueRequests.set(conversationId, false); + resolve(shouldContinue); + }); + + signal?.addEventListener( + 'abort', + () => { + const resolver = this._continueResolvers.get(conversationId); + if (resolver) { + this._continueResolvers.delete(conversationId); + this._pendingContinueRequests.set(conversationId, false); + resolve(false); + } + }, + { once: true } + ); + }); + } + + async runAgenticFlow(params: AgenticFlowParams): Promise { + const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params; + + // Clear any pending permissions/continue requests for this conversation when starting a new flow + this._pendingPermissions.set(conversationId, null); + this._permissionResolvers.delete(conversationId); + this._pendingContinueRequests.set(conversationId, false); + this._continueResolvers.delete(conversationId); + this._steeringMessages.delete(conversationId); + + // Ensure built-in tools are fetched before checking if agentic is enabled + if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) { + await toolsStore.fetchBuiltinTools(); + } + + const agenticConfig = this.getConfig(config(), perChatOverrides); + if (!agenticConfig.enabled) return { handled: false }; + + const hasMcpServers = mcpStore.hasEnabledServers(perChatOverrides); + if (hasMcpServers) { + const initialized = await mcpStore.ensureInitialized(perChatOverrides); + + if (!initialized) { + console.log('[AgenticStore] MCP not initialized'); + } + } + + const tools = toolsStore.getEnabledToolsForLLM(); + if (tools.length === 0) { + return { handled: false }; + } + + console.log(`[AgenticStore] Starting agentic flow with ${tools.length} tools`); + + const normalizedMessages: ApiChatMessageData[] = messages + .map((msg) => { + if ('id' in msg && 'convId' in msg && 'timestamp' in msg) + return ChatService.convertDbMessageToApiChatMessageData( + msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] } + ); + return msg as ApiChatMessageData; + }) + .filter((msg) => { + if (msg.role === MessageRole.SYSTEM) { + const content = typeof msg.content === 'string' ? msg.content : ''; + return content.trim().length > 0; + } + return true; + }); + + this.updateSession(conversationId, { + isRunning: true, + currentTurn: 0, + totalToolCalls: 0, + lastError: null + }); + + if (hasMcpServers) mcpStore.acquireConnection(); + + try { + await this.executeAgenticLoop({ + conversationId, + messages: normalizedMessages, + options, + tools, + agenticConfig, + callbacks, + signal + }); + return { handled: true }; + } catch (error) { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + this.updateSession(conversationId, { lastError: normalizedError }); + callbacks.onError?.(normalizedError); + return { handled: true, error: normalizedError }; + } finally { + this.updateSession(conversationId, { isRunning: false }); + + if (hasMcpServers) { + await mcpStore + .releaseConnection() + .catch((err: unknown) => + console.warn('[AgenticStore] Failed to release MCP connection:', err) + ); + } + } + } + + private async executeAgenticLoop(params: { + conversationId: string; + messages: ApiChatMessageData[]; + options: AgenticFlowOptions; + tools: ReturnType; + agenticConfig: AgenticConfig; + callbacks: AgenticFlowCallbacks; + signal?: AbortSignal; + }): Promise { + const { conversationId, messages, options, tools, agenticConfig, callbacks, signal } = params; + const { + onChunk, + onReasoningChunk, + onToolCallsStreaming, + onAttachments, + onModel, + onAssistantTurnComplete, + createToolResultMessage, + createAssistantMessage, + onFlowComplete, + onTimings, + onTurnComplete + } = callbacks; + + const sessionMessages: AgenticMessage[] = toAgenticMessages(messages); + let capturedTimings: ChatMessageTimings | undefined; + let totalToolCallCount = 0; + + const agenticTimings: ChatMessageAgenticTimings = { + turns: 0, + toolCallsCount: 0, + toolsMs: 0, + toolCalls: [], + perTurn: [], + llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 } + }; + const maxTurns = agenticConfig.maxTurns; + + const effectiveModel = options.model || modelsStore.models[0]?.model || ''; + + let turn = 0; + while (true) { + if (turn >= maxTurns) { + // Turn limit reached - ask user whether to continue + const shouldContinue = await this.requestContinue(conversationId, signal); + + // Yield to allow Svelte to flush the UI update + await new Promise((r) => setTimeout(r, 0)); + + if (!shouldContinue || signal?.aborted) { + onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); + return; + } + + // User chose to continue - extend the limit + turn = 0; + } + + this.updateSession(conversationId, { currentTurn: turn + 1 }); + agenticTimings.turns = turn + 1; + + if (signal?.aborted) { + onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); + return; + } + + // For turns > 0, create a new assistant message via callback + if (turn > 0 && createAssistantMessage) { + await createAssistantMessage(); + } + + let turnContent = ''; + let turnReasoningContent = ''; + let turnToolCalls: ApiChatCompletionToolCall[] = []; + let lastStreamingToolCallName = ''; + let lastStreamingToolCallArgsLength = 0; + let turnTimings: ChatMessageTimings | undefined; + + const turnStats: ChatMessageAgenticTurnStats = { + turn: turn + 1, + llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 }, + toolCalls: [], + toolsMs: 0 + }; + + try { + await ChatService.sendMessage( + sessionMessages as ApiChatMessageData[], + { + ...options, + stream: true, + tools: tools.length > 0 ? tools : undefined, + onChunk: (chunk: string) => { + turnContent += chunk; + onChunk?.(chunk); + }, + onReasoningChunk: (chunk: string) => { + turnReasoningContent += chunk; + onReasoningChunk?.(chunk); + }, + onToolCallChunk: (serialized: string) => { + try { + turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[]; + onToolCallsStreaming?.(turnToolCalls); + + if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) { + const name = turnToolCalls[0].function.name || ''; + const args = turnToolCalls[0].function.arguments || ''; + const argsLengthBucket = Math.floor(args.length / 100); + if ( + name !== lastStreamingToolCallName || + argsLengthBucket !== lastStreamingToolCallArgsLength + ) { + lastStreamingToolCallName = name; + lastStreamingToolCallArgsLength = argsLengthBucket; + this.updateSession(conversationId, { + streamingToolCall: { name, arguments: args } + }); + } + } + } catch { + /* Ignore parse errors during streaming */ + } + }, + onModel, + onTimings: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => { + onTimings?.(timings, progress); + if (timings) { + capturedTimings = timings; + turnTimings = timings; + } + }, + onComplete: () => { + /* Completion handled after sendMessage resolves */ + }, + onError: (error: Error) => { + throw error; + } + }, + undefined, + signal + ); + + this.updateSession(conversationId, { streamingToolCall: null }); + + if (turnTimings) { + agenticTimings.llm.predicted_n += turnTimings.predicted_n || 0; + agenticTimings.llm.predicted_ms += turnTimings.predicted_ms || 0; + agenticTimings.llm.prompt_n += turnTimings.prompt_n || 0; + agenticTimings.llm.prompt_ms += turnTimings.prompt_ms || 0; + turnStats.llm.predicted_n = turnTimings.predicted_n || 0; + turnStats.llm.predicted_ms = turnTimings.predicted_ms || 0; + turnStats.llm.prompt_n = turnTimings.prompt_n || 0; + turnStats.llm.prompt_ms = turnTimings.prompt_ms || 0; + } + } catch (error) { + if (signal?.aborted) { + // Save whatever we have for this turn before exiting + await onAssistantTurnComplete?.( + turnContent, + turnReasoningContent || undefined, + this.buildFinalTimings(capturedTimings, agenticTimings), + undefined + ); + onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); + return; + } + const normalizedError = error instanceof Error ? error : new Error('LLM stream error'); + // preserve partial output as is, the outer error dialog informs the user separately + await onAssistantTurnComplete?.( + turnContent, + turnReasoningContent || undefined, + this.buildFinalTimings(capturedTimings, agenticTimings), + undefined + ); + onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); + throw normalizedError; + } + + // === Steering check: if a user message was queued during this turn, exit the flow. + // The caller (chatStore) will consume the pending message and re-send it normally. + if (this._steeringMessages.has(conversationId)) { + console.log('[AgenticStore] Steering message detected after turn, exiting agentic flow'); + await onAssistantTurnComplete?.( + turnContent, + turnReasoningContent || undefined, + this.buildFinalTimings(capturedTimings, agenticTimings), + turnToolCalls.length > 0 ? this.normalizeToolCalls(turnToolCalls) : undefined + ); + onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); + return; + } + + // No tool calls = final turn, save and complete + if (turnToolCalls.length === 0) { + agenticTimings.perTurn!.push(turnStats); + + const finalTimings = this.buildFinalTimings(capturedTimings, agenticTimings); + + await onAssistantTurnComplete?.( + turnContent, + turnReasoningContent || undefined, + finalTimings, + undefined + ); + + if (finalTimings) onTurnComplete?.(finalTimings); + + onFlowComplete?.(finalTimings); + + return; + } + + // Normalize and save assistant turn with tool calls + const normalizedCalls = this.normalizeToolCalls(turnToolCalls); + if (normalizedCalls.length === 0) { + await onAssistantTurnComplete?.( + turnContent, + turnReasoningContent || undefined, + this.buildFinalTimings(capturedTimings, agenticTimings), + undefined + ); + onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); + return; + } + + totalToolCallCount += normalizedCalls.length; + this.updateSession(conversationId, { totalToolCalls: totalToolCallCount }); + + // Save the assistant message with its tool calls + await onAssistantTurnComplete?.( + turnContent, + turnReasoningContent || undefined, + turnTimings, + normalizedCalls + ); + + // Add assistant message to session history + sessionMessages.push({ + role: MessageRole.ASSISTANT, + content: turnContent || undefined, + reasoning_content: turnReasoningContent || undefined, + tool_calls: normalizedCalls + }); + + // Execute each tool call and create result messages + for (let i = 0; i < normalizedCalls.length; i++) { + const toolCall = normalizedCalls[i]; + + if (signal?.aborted) { + onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); + return; + } + + // Check for pending steering message - skip remaining tool calls + if (this._steeringMessages.has(conversationId)) { + console.log( + `[AgenticStore] Steering message detected, skipping ${normalizedCalls.length - i} remaining tool call(s)` + ); + for (let j = i; j < normalizedCalls.length; j++) { + const remainingCall = normalizedCalls[j]; + const interruptedContent = 'Tool execution was interrupted by a new user message.'; + if (createToolResultMessage) { + await createToolResultMessage(remainingCall.id, interruptedContent); + } + sessionMessages.push({ + role: MessageRole.TOOL, + tool_call_id: remainingCall.id, + content: interruptedContent + }); + } + break; + } + + const toolName = toolCall.function.name; + const serverLabel = toolsStore.getToolServerLabel(toolName); + + // Ask for permission before executing the tool + const permission = await this.requestPermission( + conversationId, + toolName, + serverLabel, + signal + ); + + // Yield to allow Svelte to flush the UI update (hide permission dialog) + await new Promise((r) => setTimeout(r, 0)); + + if (signal?.aborted) { + onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); + return; + } + + const toolStartTime = performance.now(); + const toolSource = toolsStore.getToolSource(toolName); + + let result: string; + let toolSuccess = true; + + if (permission === ToolPermissionDecision.DENY) { + result = 'Tool execution was denied by the user.'; + toolSuccess = false; + } else { + try { + if (toolSource === ToolSource.BUILTIN) { + const args = this.parseToolArguments(toolCall.function.arguments); + const executionResult = await ToolsService.executeTool(toolName, args, signal); + + result = executionResult.content; + + if (executionResult.isError) toolSuccess = false; + } else { + const mcpCall: MCPToolCall = { + id: toolCall.id, + function: { name: toolName, arguments: toolCall.function.arguments } + }; + const executionResult = await mcpStore.executeTool(mcpCall, signal); + + result = executionResult.content; + } + } catch (error) { + if (isAbortError(error)) { + onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); + return; + } + result = `Error: ${error instanceof Error ? error.message : String(error)}`; + toolSuccess = false; + } + } + + const toolDurationMs = performance.now() - toolStartTime; + const toolTiming: ChatMessageToolCallTiming = { + name: toolCall.function.name, + duration_ms: Math.round(toolDurationMs), + success: toolSuccess + }; + + agenticTimings.toolCalls!.push(toolTiming); + agenticTimings.toolCallsCount++; + agenticTimings.toolsMs += Math.round(toolDurationMs); + turnStats.toolCalls.push(toolTiming); + turnStats.toolsMs += Math.round(toolDurationMs); + + if (signal?.aborted) { + onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); + return; + } + + const { cleanedResult, attachments } = this.extractBase64Attachments(result); + + // Create the tool result message in the DB + let toolResultMessage: DatabaseMessage | undefined; + if (createToolResultMessage) { + toolResultMessage = await createToolResultMessage( + toolCall.id, + cleanedResult, + attachments.length > 0 ? attachments : undefined + ); + } + + if (attachments.length > 0 && toolResultMessage) { + onAttachments?.(toolResultMessage.id, attachments); + } + + // Build content parts for session history (including images for vision models) + const contentParts: ApiChatMessageContentPart[] = [ + { type: ContentPartType.TEXT, text: cleanedResult } + ]; + for (const attachment of attachments) { + if (attachment.type === AttachmentType.IMAGE) { + if (modelsStore.modelSupportsVision(effectiveModel)) { + contentParts.push({ + type: ContentPartType.IMAGE_URL, + image_url: { + url: (attachment as DatabaseMessageExtraImageFile).base64Url + } + }); + } else { + console.info( + `[AgenticStore] Skipping image attachment (model "${effectiveModel}" does not support vision)` + ); + } + } + } + + sessionMessages.push({ + role: MessageRole.TOOL, + tool_call_id: toolCall.id, + content: contentParts.length === 1 ? cleanedResult : contentParts + }); + } + + if (turnStats.toolCalls.length > 0) { + agenticTimings.perTurn!.push(turnStats); + + const intermediateTimings = this.buildFinalTimings(capturedTimings, agenticTimings); + if (intermediateTimings) onTurnComplete?.(intermediateTimings); + } + + // If tools were interrupted by a steering message, exit now instead of starting another LLM turn + if (this._steeringMessages.has(conversationId)) { + console.log( + '[AgenticStore] Steering message detected after tool execution, exiting agentic flow' + ); + onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings)); + return; + } + + turn++; + } + } + + private buildFinalTimings( + capturedTimings: ChatMessageTimings | undefined, + agenticTimings: ChatMessageAgenticTimings + ): ChatMessageTimings | undefined { + if (agenticTimings.toolCallsCount === 0) return capturedTimings; + return { + predicted_n: capturedTimings?.predicted_n, + predicted_ms: capturedTimings?.predicted_ms, + prompt_n: capturedTimings?.prompt_n, + prompt_ms: capturedTimings?.prompt_ms, + cache_n: capturedTimings?.cache_n, + agentic: agenticTimings + }; + } + + private normalizeToolCalls(toolCalls: ApiChatCompletionToolCall[]): AgenticToolCallList { + if (!toolCalls) return []; + return toolCalls.map((call, index) => ({ + id: call?.id ?? `tool_${index}`, + type: (call?.type as ToolCallType.FUNCTION) ?? ToolCallType.FUNCTION, + function: { + name: call?.function?.name ?? '', + arguments: call?.function?.arguments ?? '' + } + })); + } + + private extractBase64Attachments(result: string): { + cleanedResult: string; + attachments: DatabaseMessageExtra[]; + } { + if (!result.trim()) { + return { cleanedResult: result, attachments: [] }; + } + + const lines = result.split(NEWLINE_SEPARATOR); + const attachments: DatabaseMessageExtra[] = []; + let attachmentIndex = 0; + + const cleanedLines = lines.map((line) => { + const trimmedLine = line.trim(); + + const match = trimmedLine.match(DATA_URI_BASE64_REGEX); + if (!match) { + return line; + } + + const mimeType = match[1].toLowerCase(); + const base64Data = match[2]; + + if (!base64Data) { + return line; + } + + attachmentIndex += 1; + const name = this.buildAttachmentName(mimeType, attachmentIndex); + + if (mimeType.startsWith(MimeTypePrefix.IMAGE)) { + attachments.push({ type: AttachmentType.IMAGE, name, base64Url: trimmedLine }); + + return `[Attachment saved: ${name}]`; + } + + return line; + }); + + return { cleanedResult: cleanedLines.join(NEWLINE_SEPARATOR), attachments }; + } + + private buildAttachmentName(mimeType: string, index: number): string { + const extension = IMAGE_MIME_TO_EXTENSION[mimeType] ?? DEFAULT_IMAGE_EXTENSION; + + return `${MCP_ATTACHMENT_NAME_PREFIX}-${Date.now()}-${index}.${extension}`; + } +} + +export const agenticStore = new AgenticStore(); + +export function agenticIsRunning(conversationId: string) { + return agenticStore.isRunning(conversationId); +} + +export function agenticCurrentTurn(conversationId: string) { + return agenticStore.currentTurn(conversationId); +} + +export function agenticTotalToolCalls(conversationId: string) { + return agenticStore.totalToolCalls(conversationId); +} + +export function agenticLastError(conversationId: string) { + return agenticStore.lastError(conversationId); +} + +export function agenticStreamingToolCall(conversationId: string) { + return agenticStore.streamingToolCall(conversationId); +} + +export function agenticPendingPermissionRequest(conversationId: string) { + return agenticStore.pendingPermissionRequest(conversationId); +} + +export function agenticResolvePermission(conversationId: string, decision: ToolPermissionDecision) { + agenticStore.resolvePermission(conversationId, decision); +} + +export function agenticPendingContinueRequest(conversationId: string) { + return agenticStore.pendingContinueRequest(conversationId); +} + +export function agenticResolveContinue(conversationId: string, shouldContinue: boolean) { + agenticStore.resolveContinue(conversationId, shouldContinue); +} + +export function agenticHasPendingSteeringMessage(conversationId: string) { + return agenticStore.hasPendingSteeringMessage(conversationId); +} + +export function agenticInjectSteeringMessage( + conversationId: string, + content: string, + extras?: DatabaseMessageExtra[] +) { + agenticStore.injectSteeringMessage(conversationId, content, extras); +} + +export function agenticPendingSteeringMessageContent(conversationId: string) { + return agenticStore.pendingSteeringMessageContent(conversationId); +} + +export function agenticPendingSteeringMessageExtras(conversationId: string) { + return agenticStore.pendingSteeringMessageExtras(conversationId); +} + +export function agenticClearSteeringMessage(conversationId: string) { + agenticStore.clearSteeringMessage(conversationId); +} + +export function agenticIsAnyRunning() { + return agenticStore.isAnyRunning; +} diff --git a/tools/ui/src/lib/stores/chat.svelte.ts b/tools/ui/src/lib/stores/chat.svelte.ts new file mode 100644 index 000000000..61ea4c892 --- /dev/null +++ b/tools/ui/src/lib/stores/chat.svelte.ts @@ -0,0 +1,1877 @@ +/** + * chatStore - Reactive State Store for Chat Operations + * + * Manages chat lifecycle, streaming, message operations, and processing state. + * + * **Architecture & Relationships:** + * - **ChatService**: Stateless API layer (sendMessage, streaming) + * - **chatStore** (this): Reactive state + business logic + * - **conversationsStore**: Conversation persistence and navigation + * + * @see ChatService in services/chat.service.ts for API operations + */ + +import { SvelteMap } from 'svelte/reactivity'; +import { DatabaseService } from '$lib/services/database.service'; +import { ChatService } from '$lib/services/chat.service'; +import { conversationsStore } from '$lib/stores/conversations.svelte'; +import { config } from '$lib/stores/settings.svelte'; +import { agenticStore } from '$lib/stores/agentic.svelte'; +import { mcpStore } from '$lib/stores/mcp.svelte'; +import { contextSize, isRouterMode } from '$lib/stores/server.svelte'; +import { + selectedModelName, + modelsStore, + selectedModelContextSize +} from '$lib/stores/models.svelte'; +import { + normalizeModelName, + filterByLeafNodeId, + findDescendantMessages, + findLeafNode, + findMessageById, + isAbortError, + generateConversationTitle +} from '$lib/utils'; +import { + MAX_INACTIVE_CONVERSATION_STATES, + INACTIVE_CONVERSATION_STATE_MAX_AGE_MS, + SYSTEM_MESSAGE_PLACEHOLDER, + TITLE_GENERATION +} from '$lib/constants'; +import type { + ChatMessageTimings, + ChatMessagePromptProgress, + ChatStreamCallbacks, + ErrorDialogState +} from '$lib/types/chat'; +import type { + ApiChatMessageData, + ApiProcessingState, + DatabaseMessage, + DatabaseMessageExtra +} from '$lib/types'; +import { ErrorDialogType, MessageRole, MessageType } from '$lib/enums'; + +interface ConversationStateEntry { + lastAccessed: number; +} + +class ChatStore { + activeProcessingState = $state(null); + currentResponse = $state(''); + errorDialogState = $state(null); + isLoading = $state(false); + chatLoadingStates = new SvelteMap(); + chatStreamingStates = new SvelteMap(); + private abortControllers = new SvelteMap(); + private preEncodeAbortController: AbortController | null = null; + private processingStates = new SvelteMap(); + private conversationStateTimestamps = new SvelteMap(); + private activeConversationId = $state(null); + private isStreamingActive = $state(false); + private isEditModeActive = $state(false); + private addFilesHandler: ((files: File[]) => void) | null = $state(null); + pendingEditMessageId = $state(null); + private messageUpdateCallback: + | ((messageId: string, updates: Partial) => void) + | null = null; + private _pendingDraftMessage = $state(''); + private _pendingDraftFiles = $state([]); + + /** Reactive: queued pending messages for non-agentic streaming */ + private _pendingMessages = new SvelteMap< + string, + { content: string; extras?: DatabaseMessageExtra[] } + >(); + + private setChatLoading(convId: string, loading: boolean): void { + this.touchConversationState(convId); + if (loading) { + this.chatLoadingStates.set(convId, true); + if (convId === conversationsStore.activeConversation?.id) this.isLoading = true; + } else { + this.chatLoadingStates.delete(convId); + if (convId === conversationsStore.activeConversation?.id) this.isLoading = false; + } + } + private setChatStreaming(convId: string, response: string, messageId: string): void { + this.touchConversationState(convId); + this.chatStreamingStates.set(convId, { response, messageId }); + if (convId === conversationsStore.activeConversation?.id) this.currentResponse = response; + } + private clearChatStreaming(convId: string): void { + this.chatStreamingStates.delete(convId); + if (convId === conversationsStore.activeConversation?.id) this.currentResponse = ''; + } + private getChatStreaming(convId: string): { response: string; messageId: string } | undefined { + return this.chatStreamingStates.get(convId); + } + syncLoadingStateForChat(convId: string): void { + this.isLoading = this.chatLoadingStates.get(convId) || false; + const s = this.chatStreamingStates.get(convId); + this.currentResponse = s?.response || ''; + this.isStreamingActive = s !== undefined; + this.setActiveProcessingConversation(convId); + // Sync streaming content to activeMessages so UI displays current content + if (s?.response && s?.messageId) { + const idx = conversationsStore.findMessageIndex(s.messageId); + if (idx !== -1) { + conversationsStore.updateMessageAtIndex(idx, { content: s.response }); + } + } + } + + clearUIState(): void { + this.isLoading = false; + this.currentResponse = ''; + this.isStreamingActive = false; + } + + setActiveProcessingConversation(conversationId: string | null): void { + this.activeConversationId = conversationId; + this.activeProcessingState = conversationId + ? this.processingStates.get(conversationId) || null + : null; + } + + getProcessingState(conversationId: string): ApiProcessingState | null { + return this.processingStates.get(conversationId) || null; + } + + private setProcessingState(conversationId: string, state: ApiProcessingState | null): void { + if (state === null) this.processingStates.delete(conversationId); + else this.processingStates.set(conversationId, state); + if (conversationId === this.activeConversationId) this.activeProcessingState = state; + } + + clearProcessingState(conversationId: string): void { + this.processingStates.delete(conversationId); + if (conversationId === this.activeConversationId) this.activeProcessingState = null; + } + + getActiveProcessingState(): ApiProcessingState | null { + return this.activeProcessingState; + } + + getCurrentProcessingStateSync(): ApiProcessingState | null { + return this.activeProcessingState; + } + + private setStreamingActive(active: boolean): void { + this.isStreamingActive = active; + } + + isStreaming(): boolean { + return this.isStreamingActive; + } + + private getOrCreateAbortController(convId: string): AbortController { + let c = this.abortControllers.get(convId); + if (!c || c.signal.aborted) { + c = new AbortController(); + this.abortControllers.set(convId, c); + } + return c; + } + + private abortRequest(convId?: string): void { + if (convId) { + const c = this.abortControllers.get(convId); + if (c) { + c.abort(); + this.abortControllers.delete(convId); + } + } else { + for (const c of this.abortControllers.values()) c.abort(); + this.abortControllers.clear(); + } + } + + /** + * Abort the current agentic flow signal without clearing loading state. + * Used by "Send immediately" to force the agentic loop to exit so that + * the pending steering message can be re-sent. + */ + abortCurrentFlow(convId: string): void { + const c = this.abortControllers.get(convId); + if (c) { + c.abort(); + this.abortControllers.delete(convId); + } + } + + private showErrorDialog(state: ErrorDialogState | null): void { + this.errorDialogState = state; + } + + dismissErrorDialog(): void { + this.errorDialogState = null; + } + + clearEditMode(): void { + this.isEditModeActive = false; + this.addFilesHandler = null; + } + + isEditing(): boolean { + return this.isEditModeActive; + } + + setEditModeActive(handler: (files: File[]) => void): void { + this.isEditModeActive = true; + this.addFilesHandler = handler; + } + + getAddFilesHandler(): ((files: File[]) => void) | null { + return this.addFilesHandler; + } + + clearPendingEditMessageId(): void { + this.pendingEditMessageId = null; + } + + savePendingDraft(message: string, files: ChatUploadedFile[]): void { + this._pendingDraftMessage = message; + this._pendingDraftFiles = [...files]; + } + + consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null { + if (!this._pendingDraftMessage && this._pendingDraftFiles.length === 0) return null; + const d = { message: this._pendingDraftMessage, files: [...this._pendingDraftFiles] }; + this._pendingDraftMessage = ''; + this._pendingDraftFiles = []; + return d; + } + + hasPendingDraft(): boolean { + return Boolean(this._pendingDraftMessage) || this._pendingDraftFiles.length > 0; + } + + getAllLoadingChats(): string[] { + return Array.from(this.chatLoadingStates.keys()); + } + + getAllStreamingChats(): string[] { + return Array.from(this.chatStreamingStates.keys()); + } + + getChatStreamingPublic(convId: string): { response: string; messageId: string } | undefined { + return this.getChatStreaming(convId); + } + + isChatLoadingPublic(convId: string): boolean { + return this.chatLoadingStates.get(convId) || false; + } + + private isChatLoadingInternal(convId: string): boolean { + return this.chatLoadingStates.has(convId) || this.chatStreamingStates.has(convId); + } + + hasPendingMessage(convId: string): boolean { + return this._pendingMessages.has(convId); + } + + pendingMessageContent(convId: string): string | null { + return this._pendingMessages.get(convId)?.content ?? null; + } + + pendingMessageExtras(convId: string): DatabaseMessageExtra[] | undefined { + return this._pendingMessages.get(convId)?.extras; + } + + injectPendingMessage(convId: string, content: string, extras?: DatabaseMessageExtra[]): void { + this._pendingMessages.set(convId, { content, extras }); + } + + clearPendingMessage(convId: string): void { + this._pendingMessages.delete(convId); + } + + consumePendingMessage( + convId: string + ): { content: string; extras?: DatabaseMessageExtra[] } | null { + const msg = this._pendingMessages.get(convId); + if (!msg) return null; + this._pendingMessages.delete(convId); + return msg; + } + + private touchConversationState(convId: string): void { + this.conversationStateTimestamps.set(convId, { lastAccessed: Date.now() }); + } + + cleanupOldConversationStates(activeConversationIds?: string[]): number { + const now = Date.now(); + const activeIdsList = activeConversationIds ?? []; + const preserveIds = this.activeConversationId + ? [...activeIdsList, this.activeConversationId] + : activeIdsList; + const allConvIds = [ + ...new Set([ + ...this.chatLoadingStates.keys(), + ...this.chatStreamingStates.keys(), + ...this.abortControllers.keys(), + ...this.processingStates.keys(), + ...this.conversationStateTimestamps.keys() + ]) + ]; + const cleanupCandidates: Array<{ convId: string; lastAccessed: number }> = []; + for (const convId of allConvIds) { + if (preserveIds.includes(convId)) continue; + if (this.chatLoadingStates.get(convId)) continue; + if (this.chatStreamingStates.has(convId)) continue; + const ts = this.conversationStateTimestamps.get(convId); + cleanupCandidates.push({ convId, lastAccessed: ts?.lastAccessed ?? 0 }); + } + cleanupCandidates.sort((a, b) => a.lastAccessed - b.lastAccessed); + let cleanedUp = 0; + for (const { convId, lastAccessed } of cleanupCandidates) { + if ( + cleanupCandidates.length - cleanedUp > MAX_INACTIVE_CONVERSATION_STATES || + now - lastAccessed > INACTIVE_CONVERSATION_STATE_MAX_AGE_MS + ) { + this.cleanupConversationState(convId); + cleanedUp++; + } + } + return cleanedUp; + } + private cleanupConversationState(convId: string): void { + const c = this.abortControllers.get(convId); + if (c && !c.signal.aborted) c.abort(); + this.chatLoadingStates.delete(convId); + this.chatStreamingStates.delete(convId); + this.abortControllers.delete(convId); + this.processingStates.delete(convId); + this.conversationStateTimestamps.delete(convId); + } + getTrackedConversationCount(): number { + return new Set([ + ...this.chatLoadingStates.keys(), + ...this.chatStreamingStates.keys(), + ...this.abortControllers.keys(), + ...this.processingStates.keys() + ]).size; + } + + private getMessageByIdWithRole( + messageId: string, + expectedRole?: MessageRole + ): { message: DatabaseMessage; index: number } | null { + const index = conversationsStore.findMessageIndex(messageId); + if (index === -1) return null; + const message = conversationsStore.activeMessages[index]; + if (expectedRole && message.role !== expectedRole) return null; + return { message, index }; + } + + async addMessage( + role: MessageRole, + content: string, + type: MessageType = MessageType.TEXT, + parent: string = '-1', + extras?: DatabaseMessageExtra[] + ): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) throw new Error('No active conversation'); + let parentId: string | null = null; + if (parent === '-1') { + const am = conversationsStore.activeMessages; + if (am.length > 0) parentId = am[am.length - 1].id; + else { + const all = await conversationsStore.getConversationMessages(activeConv.id); + const r = all.find((m) => m.parent === null && m.type === 'root'); + parentId = r ? r.id : await DatabaseService.createRootMessage(activeConv.id); + } + } else parentId = parent; + const message = await DatabaseService.createMessageBranch( + { + convId: activeConv.id, + role, + content, + type, + timestamp: Date.now(), + toolCalls: '', + children: [], + extra: extras + }, + parentId + ); + conversationsStore.addMessageToActive(message); + await conversationsStore.updateCurrentNode(message.id); + conversationsStore.updateConversationTimestamp(); + return message; + } + + async addSystemPrompt(): Promise { + let activeConv = conversationsStore.activeConversation; + if (!activeConv) { + await conversationsStore.createConversation(); + activeConv = conversationsStore.activeConversation; + } + if (!activeConv) return; + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + const rootId = rootMessage + ? rootMessage.id + : await DatabaseService.createRootMessage(activeConv.id); + const existingSystemMessage = allMessages.find( + (m) => m.role === MessageRole.SYSTEM && m.parent === rootId + ); + if (existingSystemMessage) { + this.pendingEditMessageId = existingSystemMessage.id; + if (!conversationsStore.activeMessages.some((m) => m.id === existingSystemMessage.id)) + conversationsStore.activeMessages.unshift(existingSystemMessage); + return; + } + const am = conversationsStore.activeMessages; + const firstActiveMessage = am.find((m) => m.parent === rootId); + const systemMessage = await DatabaseService.createSystemMessage( + activeConv.id, + SYSTEM_MESSAGE_PLACEHOLDER, + rootId + ); + if (firstActiveMessage) { + await DatabaseService.updateMessage(firstActiveMessage.id, { + parent: systemMessage.id + }); + await DatabaseService.updateMessage(systemMessage.id, { + children: [firstActiveMessage.id] + }); + const updatedRootChildren = rootMessage + ? rootMessage.children.filter((id: string) => id !== firstActiveMessage.id) + : []; + await DatabaseService.updateMessage(rootId, { + children: [ + ...updatedRootChildren.filter((id: string) => id !== systemMessage.id), + systemMessage.id + ] + }); + const firstMsgIndex = conversationsStore.findMessageIndex(firstActiveMessage.id); + if (firstMsgIndex !== -1) + conversationsStore.updateMessageAtIndex(firstMsgIndex, { + parent: systemMessage.id + }); + } + conversationsStore.activeMessages.unshift(systemMessage); + this.pendingEditMessageId = systemMessage.id; + conversationsStore.updateConversationTimestamp(); + } catch (error) { + console.error('Failed to add system prompt:', error); + } + } + + async removeSystemPromptPlaceholder(messageId: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return false; + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const systemMessage = findMessageById(allMessages, messageId); + if (!systemMessage || systemMessage.role !== MessageRole.SYSTEM) return false; + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + if (!rootMessage) return false; + if (allMessages.length === 2 && systemMessage.children.length === 0) { + await conversationsStore.deleteConversation(activeConv.id); + return true; + } + for (const childId of systemMessage.children) { + await DatabaseService.updateMessage(childId, { parent: rootMessage.id }); + const childIndex = conversationsStore.findMessageIndex(childId); + if (childIndex !== -1) + conversationsStore.updateMessageAtIndex(childIndex, { parent: rootMessage.id }); + } + await DatabaseService.updateMessage(rootMessage.id, { + children: [ + ...rootMessage.children.filter((id: string) => id !== messageId), + ...systemMessage.children + ] + }); + await DatabaseService.deleteMessage(messageId); + const systemIndex = conversationsStore.findMessageIndex(messageId); + if (systemIndex !== -1) conversationsStore.activeMessages.splice(systemIndex, 1); + conversationsStore.updateConversationTimestamp(); + return false; + } catch (error) { + console.error('Failed to remove system prompt placeholder:', error); + return false; + } + } + + private async createAssistantMessage(parentId?: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) throw new Error('No active conversation'); + return await DatabaseService.createMessageBranch( + { + convId: activeConv.id, + type: MessageType.TEXT, + role: MessageRole.ASSISTANT, + content: '', + timestamp: Date.now(), + toolCalls: '', + children: [], + model: null + }, + parentId || null + ); + } + + async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise { + if (!content.trim() && (!extras || extras.length === 0)) return; + const activeConv = conversationsStore.activeConversation; + + // If agentic loop is running, inject as a steering message instead of starting a new flow + if (activeConv && agenticStore.isRunning(activeConv.id)) { + agenticStore.injectSteeringMessage(activeConv.id, content, extras); + return; + } + + // If non-agentic streaming is active, queue as a pending message to send after completion + if (activeConv && this.isChatLoadingInternal(activeConv.id)) { + this.injectPendingMessage(activeConv.id, content, extras); + return; + } + + // Cancel any in-flight pre-encode request + this.cancelPreEncode(); + + // Consume MCP resource attachments - converts them to extras and clears the live store + const resourceExtras = mcpStore.consumeResourceAttachmentsAsExtras(); + const allExtras = resourceExtras.length > 0 ? [...(extras || []), ...resourceExtras] : extras; + + let isNewConversation = false; + if (!activeConv) { + await conversationsStore.createConversation(); + isNewConversation = true; + } + const currentConv = conversationsStore.activeConversation; + if (!currentConv) return; + this.showErrorDialog(null); + this.setChatLoading(currentConv.id, true); + this.clearChatStreaming(currentConv.id); + try { + let parentIdForUserMessage: string | undefined; + if (isNewConversation) { + const rootId = await DatabaseService.createRootMessage(currentConv.id); + const currentConfig = config(); + const systemPrompt = currentConfig.systemMessage?.toString().trim(); + if (systemPrompt) { + const systemMessage = await DatabaseService.createSystemMessage( + currentConv.id, + systemPrompt, + rootId + ); + conversationsStore.addMessageToActive(systemMessage); + parentIdForUserMessage = systemMessage.id; + } else parentIdForUserMessage = rootId; + } + const userMessage = await this.addMessage( + MessageRole.USER, + content, + MessageType.TEXT, + parentIdForUserMessage ?? '-1', + allExtras + ); + if (isNewConversation && content) + await conversationsStore.updateConversationName( + currentConv.id, + generateConversationTitle(content, Boolean(config().titleGenerationUseFirstLine)) + ); + const assistantMessage = await this.createAssistantMessage(userMessage.id); + conversationsStore.addMessageToActive(assistantMessage); + await this.streamChatCompletion( + conversationsStore.activeMessages.slice(0, -1), + assistantMessage, + undefined, + undefined, + undefined, + config().titleGenerationUseLLM && isNewConversation ? content : undefined + ); + } catch (error) { + if (isAbortError(error)) { + this.setChatLoading(currentConv.id, false); + return; + } + console.error('Failed to send message:', error); + this.setChatLoading(currentConv.id, false); + const dialogType = + error instanceof Error && error.name === 'TimeoutError' + ? ErrorDialogType.TIMEOUT + : ErrorDialogType.SERVER; + const contextInfo = ( + error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } + ).contextInfo; + this.showErrorDialog({ + type: dialogType, + message: error instanceof Error ? error.message : 'Unknown error', + contextInfo + }); + } + } + + private async streamChatCompletion( + allMessages: DatabaseMessage[], + assistantMessage: DatabaseMessage, + onComplete?: (content: string) => Promise, + onError?: (error: Error) => void, + modelOverride?: string | null, + firstUserMessageContent?: string + ): Promise { + let effectiveModel = modelOverride; + + if (isRouterMode() && !effectiveModel) { + const conversationModel = this.getConversationModel(allMessages); + effectiveModel = selectedModelName() || conversationModel; + } + + if (isRouterMode() && effectiveModel) { + if (!modelsStore.getModelProps(effectiveModel)) + await modelsStore.fetchModelProps(effectiveModel); + } + + // Mutable state for the current message being streamed + let currentMessageId = assistantMessage.id; + let streamedContent = ''; + let streamedReasoningContent = ''; + let resolvedModel: string | null = null; + let modelPersisted = false; + const convId = assistantMessage.convId; + + const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => { + if (!modelName) return; + const n = normalizeModelName(modelName); + if (!n || n === resolvedModel) return; + resolvedModel = n; + const idx = conversationsStore.findMessageIndex(currentMessageId); + conversationsStore.updateMessageAtIndex(idx, { model: n }); + if (persistImmediately && !modelPersisted) { + modelPersisted = true; + DatabaseService.updateMessage(currentMessageId, { model: n }).catch(() => { + modelPersisted = false; + resolvedModel = null; + }); + } + }; + + const updateStreamingUI = () => { + this.setChatStreaming(convId, streamedContent, currentMessageId); + const idx = conversationsStore.findMessageIndex(currentMessageId); + conversationsStore.updateMessageAtIndex(idx, { content: streamedContent }); + }; + + const cleanupStreamingState = () => { + this.setStreamingActive(false); + this.setChatLoading(convId, false); + this.clearChatStreaming(convId); + this.setProcessingState(convId, null); + }; + + this.setStreamingActive(true); + this.setActiveProcessingConversation(convId); + const abortController = this.getOrCreateAbortController(convId); + + const streamCallbacks: ChatStreamCallbacks = { + onChunk: (chunk: string) => { + streamedContent += chunk; + updateStreamingUI(); + }, + onReasoningChunk: (chunk: string) => { + streamedReasoningContent += chunk; + // mark streaming state so a stop mid-thinking can persist the partial reasoning + this.setChatStreaming(convId, streamedContent, currentMessageId); + const idx = conversationsStore.findMessageIndex(currentMessageId); + conversationsStore.updateMessageAtIndex(idx, { + reasoningContent: streamedReasoningContent + }); + }, + onToolCallsStreaming: (toolCalls) => { + const idx = conversationsStore.findMessageIndex(currentMessageId); + conversationsStore.updateMessageAtIndex(idx, { + toolCalls: JSON.stringify(toolCalls) + }); + }, + onAttachments: (messageId: string, extras: DatabaseMessageExtra[]) => { + if (!extras.length) return; + const idx = conversationsStore.findMessageIndex(messageId); + if (idx === -1) return; + const msg = conversationsStore.activeMessages[idx]; + const updatedExtras = [...(msg.extra || []), ...extras]; + conversationsStore.updateMessageAtIndex(idx, { extra: updatedExtras }); + DatabaseService.updateMessage(messageId, { extra: updatedExtras }).catch(console.error); + }, + onModel: (modelName: string) => recordModel(modelName), + onTurnComplete: (intermediateTimings: ChatMessageTimings) => { + // Update the first assistant message with cumulative agentic timings + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + conversationsStore.updateMessageAtIndex(idx, { timings: intermediateTimings }); + }, + onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => { + const tokensPerSecond = + timings?.predicted_ms && timings?.predicted_n + ? (timings.predicted_n / timings.predicted_ms) * 1000 + : 0; + this.updateProcessingStateFromTimings( + { + prompt_n: timings?.prompt_n || 0, + prompt_ms: timings?.prompt_ms, + predicted_n: timings?.predicted_n || 0, + predicted_per_second: tokensPerSecond, + cache_n: timings?.cache_n || 0, + prompt_progress: promptProgress + }, + convId + ); + }, + onAssistantTurnComplete: async ( + content: string, + reasoningContent: string | undefined, + timings: ChatMessageTimings | undefined, + toolCalls: import('$lib/types/api').ApiChatCompletionToolCall[] | undefined + ) => { + const updateData: Record = { + content, + reasoningContent: reasoningContent || undefined, + toolCalls: toolCalls ? JSON.stringify(toolCalls) : '', + timings + }; + if (resolvedModel && !modelPersisted) updateData.model = resolvedModel; + await DatabaseService.updateMessage(currentMessageId, updateData); + const idx = conversationsStore.findMessageIndex(currentMessageId); + const uiUpdate: Partial = { + content, + reasoningContent: reasoningContent || undefined, + toolCalls: toolCalls ? JSON.stringify(toolCalls) : '' + }; + if (timings) uiUpdate.timings = timings; + if (resolvedModel) uiUpdate.model = resolvedModel; + conversationsStore.updateMessageAtIndex(idx, uiUpdate); + await conversationsStore.updateCurrentNode(currentMessageId); + }, + createToolResultMessage: async ( + toolCallId: string, + content: string, + extras?: DatabaseMessageExtra[] + ) => { + const msg = await DatabaseService.createMessageBranch( + { + convId, + type: MessageType.TEXT, + role: MessageRole.TOOL, + content, + toolCallId, + timestamp: Date.now(), + toolCalls: '', + children: [], + extra: extras + }, + currentMessageId + ); + conversationsStore.addMessageToActive(msg); + await conversationsStore.updateCurrentNode(msg.id); + return msg; + }, + createAssistantMessage: async () => { + // Reset streaming state for new message + streamedContent = ''; + streamedReasoningContent = ''; + + const lastMsg = + conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1]; + const msg = await DatabaseService.createMessageBranch( + { + convId, + type: MessageType.TEXT, + role: MessageRole.ASSISTANT, + content: '', + timestamp: Date.now(), + toolCalls: '', + children: [], + model: resolvedModel + }, + lastMsg.id + ); + conversationsStore.addMessageToActive(msg); + currentMessageId = msg.id; + return msg; + }, + onFlowComplete: (finalTimings?: ChatMessageTimings) => { + if (finalTimings) { + const idx = conversationsStore.findMessageIndex(assistantMessage.id); + + conversationsStore.updateMessageAtIndex(idx, { timings: finalTimings }); + DatabaseService.updateMessage(assistantMessage.id, { + timings: finalTimings + }).catch(console.error); + } + + cleanupStreamingState(); + + if (onComplete) onComplete(streamedContent); + if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error); + // Pre-encode conversation in KV cache for faster next turn + if (config().preEncodeConversation) { + this.triggerPreEncode( + allMessages, + assistantMessage, + streamedContent, + effectiveModel, + !!config().excludeReasoningFromContext + ); + } + }, + onError: async (error: Error) => { + this.setStreamingActive(false); + if (isAbortError(error)) { + cleanupStreamingState(); + // If aborted with a pending message (e.g. "Send immediately"), re-send it + const pending = this.consumePendingMessage(convId); + if (pending) { + this.sendMessage(pending.content, pending.extras); + } + return; + } + console.error('Streaming error:', error); + // keep whatever was streamed so far, the message stays in memory and in DB + await this.savePartialResponseIfNeeded(convId); + cleanupStreamingState(); + this.clearPendingMessage(convId); + + const contextInfo = ( + error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } } + ).contextInfo; + this.showErrorDialog({ + type: error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER, + message: error.message, + contextInfo + }); + if (onError) onError(error); + } + }; + + const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides; + + { + const agenticResult = await agenticStore.runAgenticFlow({ + conversationId: convId, + messages: allMessages, + options: { + ...this.getApiOptions(), + ...(effectiveModel ? { model: effectiveModel } : {}) + }, + callbacks: streamCallbacks, + signal: abortController.signal, + perChatOverrides + }); + if (agenticResult.handled) { + // Generate LLM based title for new conversations after agentic flow completes + if (firstUserMessageContent) { + await this.generateTitleWithLLM(firstUserMessageContent, streamedContent, convId); + } + // Check if there's a pending steering message to re-send + const pending = agenticStore.consumePendingSteeringMessage(convId); + if (pending) { + await this.sendMessage(pending.content, pending.extras); + } + return; + } + } + + await ChatService.sendMessage( + allMessages, + { + ...this.getApiOptions(), + ...(effectiveModel ? { model: effectiveModel } : {}), + stream: true, + onChunk: streamCallbacks.onChunk, + onReasoningChunk: streamCallbacks.onReasoningChunk, + onModel: streamCallbacks.onModel, + onTimings: streamCallbacks.onTimings, + onComplete: async ( + finalContent?: string, + reasoningContent?: string, + timings?: ChatMessageTimings, + toolCalls?: string + ) => { + const content = streamedContent || finalContent || ''; + const reasoning = streamedReasoningContent || reasoningContent; + const updateData: Record = { + content, + reasoningContent: reasoning || undefined, + toolCalls: toolCalls || '', + timings + }; + if (resolvedModel && !modelPersisted) updateData.model = resolvedModel; + await DatabaseService.updateMessage(currentMessageId, updateData); + const idx = conversationsStore.findMessageIndex(currentMessageId); + const uiUpdate: Partial = { + content, + reasoningContent: reasoning || undefined, + toolCalls: toolCalls || '' + }; + if (timings) uiUpdate.timings = timings; + if (resolvedModel) uiUpdate.model = resolvedModel; + conversationsStore.updateMessageAtIndex(idx, uiUpdate); + await conversationsStore.updateCurrentNode(currentMessageId); + cleanupStreamingState(); + if (onComplete) await onComplete(content); + if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error); + + // Generate LLM based title for new conversations (avoids stale reference + // issue when user switches conversations while streaming) + if (firstUserMessageContent) { + await this.generateTitleWithLLM(firstUserMessageContent, streamedContent, convId); + } + + // Check if there's a pending message queued during streaming + const pending = this.consumePendingMessage(convId); + if (pending) { + await this.sendMessage(pending.content, pending.extras); + } + }, + onError: streamCallbacks.onError + }, + convId, + abortController.signal + ); + } + + async stopGeneration(): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return; + await this.stopGenerationForChat(activeConv.id); + } + async stopGenerationForChat(convId: string): Promise { + await this.savePartialResponseIfNeeded(convId); + this.setStreamingActive(false); + this.abortRequest(convId); + this.setChatLoading(convId, false); + this.clearChatStreaming(convId); + this.setProcessingState(convId, null); + this.clearPendingMessage(convId); + } + + private async generateTitleWithLLM( + userContent: string, + assistantContent: string, + convId: string + ): Promise { + const effectiveModel = isRouterMode() && selectedModelName() ? selectedModelName() : undefined; + const configValue = config(); + const titlePromptTemplate = + typeof configValue.titleGenerationPrompt === 'string' && + configValue.titleGenerationPrompt.trim() + ? configValue.titleGenerationPrompt + : TITLE_GENERATION.DEFAULT_PROMPT; + + const titlePrompt = titlePromptTemplate + .replace('{{USER}}', String(userContent || '')) + .replace('{{ASSISTANT}}', String(assistantContent || '')); + + const titleMessage: ApiChatMessageData = { + role: MessageRole.USER, + content: titlePrompt + }; + + const titleResponse = await ChatService.generateTitle(titleMessage, effectiveModel); + + if (!titleResponse) { + return; + } + + let cleanTitle = titleResponse.trim(); + cleanTitle = cleanTitle + .replace(TITLE_GENERATION.PREFIX_PATTERN, '') + .replace(TITLE_GENERATION.QUOTE_PATTERN, '') + .trim(); + if (!cleanTitle || cleanTitle.length < TITLE_GENERATION.MIN_LENGTH) { + const firstLine = userContent.split('\n').find((l) => l.trim().length > 0); + cleanTitle = firstLine ? firstLine.trim() : TITLE_GENERATION.FALLBACK; + } + if (cleanTitle && cleanTitle.length >= TITLE_GENERATION.MIN_LENGTH) { + await conversationsStore.updateConversationName(convId, cleanTitle); + } + } + + private async savePartialResponseIfNeeded(convId?: string): Promise { + const conversationId = convId || conversationsStore.activeConversation?.id; + if (!conversationId) return; + const streamingState = this.getChatStreaming(conversationId); + if (!streamingState) return; + const messages = + conversationId === conversationsStore.activeConversation?.id + ? conversationsStore.activeMessages + : await conversationsStore.getConversationMessages(conversationId); + if (!messages.length) return; + const lastMessage = messages[messages.length - 1]; + if (lastMessage?.role !== MessageRole.ASSISTANT) return; + + const partialContent = streamingState.response; + const partialReasoning = lastMessage.reasoningContent || ''; + + // nothing to persist when both content and reasoning are empty (e.g. stop before any token) + if (!partialContent.trim() && !partialReasoning.trim()) return; + + try { + const updateData: { + content: string; + reasoningContent?: string; + timings?: ChatMessageTimings; + } = { + content: partialContent + }; + if (partialReasoning) { + updateData.reasoningContent = partialReasoning; + } + const lastKnownState = this.getProcessingState(conversationId); + if (lastKnownState) { + updateData.timings = { + prompt_n: lastKnownState.promptTokens || 0, + prompt_ms: lastKnownState.promptMs, + predicted_n: lastKnownState.tokensDecoded || 0, + cache_n: lastKnownState.cacheTokens || 0, + predicted_ms: + lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded + ? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000 + : undefined + }; + } + await DatabaseService.updateMessage(lastMessage.id, updateData); + lastMessage.content = partialContent; + if (updateData.timings) lastMessage.timings = updateData.timings; + } catch (error) { + lastMessage.content = partialContent; + console.error('Failed to save partial response:', error); + } + } + + async updateMessage(messageId: string, newContent: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return; + if (this.isChatLoadingInternal(activeConv.id)) await this.stopGeneration(); + const result = this.getMessageByIdWithRole(messageId, MessageRole.USER); + if (!result) return; + const { message: messageToUpdate, index: messageIndex } = result; + const originalContent = messageToUpdate.content; + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id; + conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent }); + await DatabaseService.updateMessage(messageId, { content: newContent }); + if (isFirstUserMessage && newContent.trim()) + await conversationsStore.updateConversationTitleWithConfirmation( + activeConv.id, + generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine)) + ); + const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1); + for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id); + conversationsStore.sliceActiveMessages(messageIndex + 1); + conversationsStore.updateConversationTimestamp(); + this.setChatLoading(activeConv.id, true); + this.clearChatStreaming(activeConv.id); + const assistantMessage = await this.createAssistantMessage(); + conversationsStore.addMessageToActive(assistantMessage); + await conversationsStore.updateCurrentNode(assistantMessage.id); + await this.streamChatCompletion( + conversationsStore.activeMessages.slice(0, -1), + assistantMessage, + undefined, + () => { + conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(messageId), { + content: originalContent + }); + } + ); + } catch (error) { + if (!isAbortError(error)) console.error('Failed to update message:', error); + } + } + + async regenerateMessage(messageId: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; + this.cancelPreEncode(); + const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT); + if (!result) return; + const { index: messageIndex } = result; + try { + const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex); + for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id); + conversationsStore.sliceActiveMessages(messageIndex); + conversationsStore.updateConversationTimestamp(); + this.setChatLoading(activeConv.id, true); + this.clearChatStreaming(activeConv.id); + const parentMessageId = + conversationsStore.activeMessages.length > 0 + ? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id + : undefined; + const assistantMessage = await this.createAssistantMessage(parentMessageId); + conversationsStore.addMessageToActive(assistantMessage); + await this.streamChatCompletion( + conversationsStore.activeMessages.slice(0, -1), + assistantMessage + ); + } catch (error) { + if (!isAbortError(error)) console.error('Failed to regenerate message:', error); + this.setChatLoading(activeConv?.id || '', false); + } + } + + async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; + this.cancelPreEncode(); + try { + const idx = conversationsStore.findMessageIndex(messageId); + if (idx === -1) return; + const msg = conversationsStore.activeMessages[idx]; + if (msg.role !== MessageRole.ASSISTANT) return; + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const parentMessage = findMessageById(allMessages, msg.parent); + if (!parentMessage) return; + this.setChatLoading(activeConv.id, true); + this.clearChatStreaming(activeConv.id); + const newAssistantMessage = await DatabaseService.createMessageBranch( + { + convId: msg.convId, + type: msg.type, + timestamp: Date.now(), + role: msg.role, + content: '', + toolCalls: '', + children: [], + model: null + }, + parentMessage.id + ); + await conversationsStore.updateCurrentNode(newAssistantMessage.id); + conversationsStore.updateConversationTimestamp(); + await conversationsStore.refreshActiveMessages(); + const conversationPath = filterByLeafNodeId( + allMessages, + parentMessage.id, + false + ) as DatabaseMessage[]; + const modelToUse = modelOverride || msg.model || undefined; + await this.streamChatCompletion( + conversationPath, + newAssistantMessage, + undefined, + undefined, + modelToUse + ); + } catch (error) { + if (!isAbortError(error)) + console.error('Failed to regenerate message with branching:', error); + this.setChatLoading(activeConv?.id || '', false); + } + } + + async getDeletionInfo(messageId: string): Promise<{ + totalCount: number; + userMessages: number; + assistantMessages: number; + messageTypes: string[]; + }> { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) + return { totalCount: 0, userMessages: 0, assistantMessages: 0, messageTypes: [] }; + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const messageToDelete = findMessageById(allMessages, messageId); + + // For system messages, don't count descendants as they will be preserved (reparented to root) + if (messageToDelete?.role === MessageRole.SYSTEM) { + const messagesToDelete = allMessages.filter((m) => m.id === messageId); + let userMessages = 0, + assistantMessages = 0; + const messageTypes: string[] = []; + + for (const msg of messagesToDelete) { + if (msg.role === MessageRole.USER) { + userMessages++; + if (!messageTypes.includes('user message')) messageTypes.push('user message'); + } else if (msg.role === MessageRole.ASSISTANT) { + assistantMessages++; + if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response'); + } + } + + return { totalCount: 1, userMessages, assistantMessages, messageTypes }; + } + + const descendants = findDescendantMessages(allMessages, messageId); + const allToDelete = [messageId, ...descendants]; + const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id)); + let userMessages = 0, + assistantMessages = 0; + const messageTypes: string[] = []; + + for (const msg of messagesToDelete) { + if (msg.role === MessageRole.USER) { + userMessages++; + if (!messageTypes.includes('user message')) messageTypes.push('user message'); + } else if (msg.role === MessageRole.ASSISTANT) { + assistantMessages++; + if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response'); + } + } + + return { totalCount: allToDelete.length, userMessages, assistantMessages, messageTypes }; + } + + async deleteMessage(messageId: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return; + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const messageToDelete = findMessageById(allMessages, messageId); + + if (!messageToDelete) return; + + const currentPath = filterByLeafNodeId(allMessages, activeConv.currNode || '', false); + const isInCurrentPath = currentPath.some((m) => m.id === messageId); + + if (isInCurrentPath && messageToDelete.parent) { + const siblings = allMessages.filter( + (m) => m.parent === messageToDelete.parent && m.id !== messageId + ); + + if (siblings.length > 0) { + const latestSibling = siblings.reduce((latest, sibling) => + sibling.timestamp > latest.timestamp ? sibling : latest + ); + + await conversationsStore.updateCurrentNode(findLeafNode(allMessages, latestSibling.id)); + } else if (messageToDelete.parent) { + await conversationsStore.updateCurrentNode( + findLeafNode(allMessages, messageToDelete.parent) + ); + } + } + + await DatabaseService.deleteMessageCascading(activeConv.id, messageId); + await conversationsStore.refreshActiveMessages(); + + conversationsStore.updateConversationTimestamp(); + } catch (error) { + console.error('Failed to delete message:', error); + } + } + + async continueAssistantMessage(messageId: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; + const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT); + + if (!result) return; + + const { message: msg, index: idx } = result; + + try { + this.showErrorDialog(null); + this.setChatLoading(activeConv.id, true); + this.clearChatStreaming(activeConv.id); + + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const dbMessage = findMessageById(allMessages, messageId); + + if (!dbMessage) { + this.setChatLoading(activeConv.id, false); + return; + } + + const originalContent = dbMessage.content; + const originalReasoning = dbMessage.reasoningContent || ''; + const conversationContext = conversationsStore.activeMessages.slice(0, idx); + const contextWithContinue = [ + ...conversationContext, + { + role: MessageRole.ASSISTANT as const, + content: originalContent, + reasoning_content: originalReasoning || undefined + } + ]; + + let appendedContent = ''; + let appendedReasoning = ''; + let hasReceivedContent = false; + + const updateStreamingContent = (fullContent: string) => { + this.setChatStreaming(msg.convId, fullContent, msg.id); + conversationsStore.updateMessageAtIndex(idx, { content: fullContent }); + }; + + const abortController = this.getOrCreateAbortController(msg.convId); + + await ChatService.sendMessage( + contextWithContinue, + { + ...this.getApiOptions(), + continueFinalMessage: true, + onChunk: (chunk: string) => { + appendedContent += chunk; + hasReceivedContent = true; + updateStreamingContent(originalContent + appendedContent); + }, + onReasoningChunk: (chunk: string) => { + appendedReasoning += chunk; + hasReceivedContent = true; + // mark streaming state so a stop mid-thinking can persist the partial reasoning + this.setChatStreaming(msg.convId, originalContent + appendedContent, msg.id); + conversationsStore.updateMessageAtIndex(idx, { + reasoningContent: originalReasoning + appendedReasoning + }); + }, + onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => { + const tokensPerSecond = + timings?.predicted_ms && timings?.predicted_n + ? (timings.predicted_n / timings.predicted_ms) * 1000 + : 0; + this.updateProcessingStateFromTimings( + { + prompt_n: timings?.prompt_n || 0, + prompt_ms: timings?.prompt_ms, + predicted_n: timings?.predicted_n || 0, + predicted_per_second: tokensPerSecond, + cache_n: timings?.cache_n || 0, + prompt_progress: promptProgress + }, + msg.convId + ); + }, + onComplete: async ( + finalContent?: string, + reasoningContent?: string, + timings?: ChatMessageTimings + ) => { + const finalAppendedContent = hasReceivedContent ? appendedContent : finalContent || ''; + const finalAppendedReasoning = hasReceivedContent + ? appendedReasoning + : reasoningContent || ''; + const fullContent = originalContent + finalAppendedContent; + const fullReasoning = originalReasoning + finalAppendedReasoning || undefined; + + await DatabaseService.updateMessage(msg.id, { + content: fullContent, + reasoningContent: fullReasoning, + timestamp: Date.now(), + timings + }); + + conversationsStore.updateMessageAtIndex(idx, { + content: fullContent, + reasoningContent: fullReasoning, + timestamp: Date.now(), + timings + }); + + conversationsStore.updateConversationTimestamp(); + + this.setChatLoading(msg.convId, false); + this.clearChatStreaming(msg.convId); + this.setProcessingState(msg.convId, null); + }, + onError: async (error: Error) => { + if (isAbortError(error)) { + if (hasReceivedContent && appendedContent) { + await DatabaseService.updateMessage(msg.id, { + content: originalContent + appendedContent, + reasoningContent: originalReasoning + appendedReasoning || undefined, + timestamp: Date.now() + }); + + conversationsStore.updateMessageAtIndex(idx, { + content: originalContent + appendedContent, + reasoningContent: originalReasoning + appendedReasoning || undefined, + timestamp: Date.now() + }); + } + + this.setChatLoading(msg.convId, false); + this.clearChatStreaming(msg.convId); + this.setProcessingState(msg.convId, null); + + return; + } + + console.error('Continue generation error:', error); + // keep whatever was appended so far, the message stays in memory and in DB + await DatabaseService.updateMessage(msg.id, { + content: originalContent + appendedContent, + reasoningContent: originalReasoning + appendedReasoning || undefined, + timestamp: Date.now() + }); + conversationsStore.updateMessageAtIndex(idx, { + content: originalContent + appendedContent, + reasoningContent: originalReasoning + appendedReasoning || undefined, + timestamp: Date.now() + }); + + this.setChatLoading(msg.convId, false); + this.clearChatStreaming(msg.convId); + this.setProcessingState(msg.convId, null); + this.showErrorDialog({ + type: + error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER, + message: error.message + }); + } + }, + + msg.convId, + abortController.signal + ); + } catch (error) { + if (!isAbortError(error)) console.error('Failed to continue message:', error); + if (activeConv) this.setChatLoading(activeConv.id, false); + } + } + + async editAssistantMessage( + messageId: string, + newContent: string, + shouldBranch: boolean + ): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; + + const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT); + if (!result) return; + + const { message: msg, index: idx } = result; + + try { + if (shouldBranch) { + const newMessage = await DatabaseService.createMessageBranch( + { + convId: msg.convId, + type: msg.type, + timestamp: Date.now(), + role: msg.role, + content: newContent, + toolCalls: msg.toolCalls || '', + children: [], + model: msg.model + }, + msg.parent! + ); + + await conversationsStore.updateCurrentNode(newMessage.id); + } else { + await DatabaseService.updateMessage(msg.id, { content: newContent }); + conversationsStore.updateMessageAtIndex(idx, { content: newContent }); + } + + conversationsStore.updateConversationTimestamp(); + + await conversationsStore.refreshActiveMessages(); + } catch (error) { + console.error('Failed to edit assistant message:', error); + } + } + + async editUserMessagePreserveResponses( + messageId: string, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return; + + const result = this.getMessageByIdWithRole(messageId, MessageRole.USER); + if (!result) return; + + const { message: msg, index: idx } = result; + try { + const updateData: Partial = { content: newContent }; + + if (newExtras !== undefined) updateData.extra = JSON.parse(JSON.stringify(newExtras)); + + await DatabaseService.updateMessage(messageId, updateData); + + conversationsStore.updateMessageAtIndex(idx, updateData); + + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + + if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) { + await conversationsStore.updateConversationTitleWithConfirmation( + activeConv.id, + generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine)) + ); + } + + conversationsStore.updateConversationTimestamp(); + } catch (error) { + console.error('Failed to edit user message:', error); + } + } + + async editMessageWithBranching( + messageId: string, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return; + let result = this.getMessageByIdWithRole(messageId, MessageRole.USER); + if (!result) result = this.getMessageByIdWithRole(messageId, MessageRole.SYSTEM); + if (!result) return; + const { message: msg, index: idx } = result; + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + const isFirstUserMessage = + msg.role === MessageRole.USER && rootMessage && msg.parent === rootMessage.id; + const extrasToUse = + newExtras !== undefined + ? JSON.parse(JSON.stringify(newExtras)) + : msg.extra + ? JSON.parse(JSON.stringify(msg.extra)) + : undefined; + + let messageIdForResponse: string; + + const dbMsg = findMessageById(allMessages, msg.id); + const hasChildren = dbMsg ? dbMsg.children.length > 0 : msg.children.length > 0; + + if (!hasChildren) { + // No responses after this message — update in place instead of branching + const updates: Partial = { + content: newContent, + timestamp: Date.now(), + extra: extrasToUse + }; + await DatabaseService.updateMessage(msg.id, updates); + conversationsStore.updateMessageAtIndex(idx, updates); + messageIdForResponse = msg.id; + } else { + // Has children — create a new branch as sibling + const parentId = msg.parent || rootMessage?.id; + if (!parentId) return; + const newMessage = await DatabaseService.createMessageBranch( + { + convId: msg.convId, + type: msg.type, + timestamp: Date.now(), + role: msg.role, + content: newContent, + toolCalls: msg.toolCalls || '', + children: [], + extra: extrasToUse, + model: msg.model + }, + parentId + ); + await conversationsStore.updateCurrentNode(newMessage.id); + messageIdForResponse = newMessage.id; + } + + conversationsStore.updateConversationTimestamp(); + if (isFirstUserMessage && newContent.trim()) + await conversationsStore.updateConversationTitleWithConfirmation( + activeConv.id, + generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine)) + ); + await conversationsStore.refreshActiveMessages(); + if (msg.role === MessageRole.USER) + await this.generateResponseForMessage(messageIdForResponse); + } catch (error) { + console.error('Failed to edit message with branching:', error); + } + } + + private async generateResponseForMessage(userMessageId: string): Promise { + const activeConv = conversationsStore.activeConversation; + if (!activeConv) return; + + this.showErrorDialog(null); + this.setChatLoading(activeConv.id, true); + this.clearChatStreaming(activeConv.id); + + try { + const allMessages = await conversationsStore.getConversationMessages(activeConv.id); + const conversationPath = filterByLeafNodeId( + allMessages, + userMessageId, + false + ) as DatabaseMessage[]; + const assistantMessage = await DatabaseService.createMessageBranch( + { + convId: activeConv.id, + type: MessageType.TEXT, + timestamp: Date.now(), + role: MessageRole.ASSISTANT, + content: '', + toolCalls: '', + children: [], + model: null + }, + userMessageId + ); + + conversationsStore.addMessageToActive(assistantMessage); + + await this.streamChatCompletion(conversationPath, assistantMessage); + } catch (error) { + console.error('Failed to generate response:', error); + this.setChatLoading(activeConv.id, false); + } + } + + private getContextTotal(): number | null { + const activeConvId = this.activeConversationId; + const activeState = activeConvId ? this.getProcessingState(activeConvId) : null; + + if (activeState && typeof activeState.contextTotal === 'number' && activeState.contextTotal > 0) + return activeState.contextTotal; + + if (isRouterMode()) { + const modelContextSize = selectedModelContextSize(); + + if (typeof modelContextSize === 'number' && modelContextSize > 0) { + return modelContextSize; + } + } else { + const propsContextSize = contextSize(); + + if (typeof propsContextSize === 'number' && propsContextSize > 0) { + return propsContextSize; + } + } + + return null; + } + + updateProcessingStateFromTimings( + timingData: { + prompt_n: number; + prompt_ms?: number; + predicted_n: number; + predicted_per_second: number; + cache_n: number; + prompt_progress?: ChatMessagePromptProgress; + }, + conversationId?: string + ): void { + const processingState = this.parseTimingData(timingData); + + if (processingState === null) { + console.warn('Failed to parse timing data - skipping update'); + return; + } + + const targetId = conversationId || this.activeConversationId; + if (targetId) { + this.setProcessingState(targetId, processingState); + } + } + + private parseTimingData(timingData: Record): ApiProcessingState | null { + const promptTokens = (timingData.prompt_n as number) || 0, + promptMs = (timingData.prompt_ms as number) || undefined, + predictedTokens = (timingData.predicted_n as number) || 0, + tokensPerSecond = (timingData.predicted_per_second as number) || 0, + cacheTokens = (timingData.cache_n as number) || 0; + const promptProgress = timingData.prompt_progress as + | { total: number; cache: number; processed: number; time_ms: number } + | undefined; + const contextTotal = this.getContextTotal(); + const currentConfig = config(); + const outputTokensMax = currentConfig.max_tokens || -1; + const contextUsed = promptTokens + cacheTokens + predictedTokens, + outputTokensUsed = predictedTokens; + const progressCache = promptProgress?.cache || 0, + progressActualDone = (promptProgress?.processed ?? 0) - progressCache, + progressActualTotal = (promptProgress?.total ?? 0) - progressCache; + const progressPercent = promptProgress + ? Math.round((progressActualDone / progressActualTotal) * 100) + : undefined; + return { + status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle', + tokensDecoded: predictedTokens, + tokensRemaining: outputTokensMax - predictedTokens, + contextUsed, + contextTotal, + outputTokensUsed, + outputTokensMax, + hasNextToken: predictedTokens > 0, + tokensPerSecond, + temperature: currentConfig.temperature ?? 0.8, + topP: currentConfig.top_p ?? 0.95, + speculative: false, + progressPercent, + promptProgress, + promptTokens, + promptMs, + cacheTokens + }; + } + + restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role === MessageRole.ASSISTANT && message.timings) { + const restoredState = this.parseTimingData({ + prompt_n: message.timings.prompt_n || 0, + prompt_ms: message.timings.prompt_ms, + predicted_n: message.timings.predicted_n || 0, + predicted_per_second: + message.timings.predicted_n && message.timings.predicted_ms + ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000 + : 0, + cache_n: message.timings.cache_n || 0 + }); + if (restoredState) { + this.setProcessingState(conversationId, restoredState); + return; + } + } + } + } + + getConversationModel(messages: DatabaseMessage[]): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role === MessageRole.ASSISTANT && message.model) return message.model; + } + return null; + } + + private getApiOptions(): Record { + const currentConfig = config(); + const hasValue = (value: unknown): boolean => + value !== undefined && value !== null && value !== ''; + const apiOptions: Record = { stream: true, timings_per_token: true }; + + if (isRouterMode()) { + const modelName = selectedModelName(); + if (modelName) apiOptions.model = modelName; + } + + if (currentConfig.systemMessage) apiOptions.systemMessage = currentConfig.systemMessage; + + if (currentConfig.disableReasoningParsing) apiOptions.disableReasoningParsing = true; + + if (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true; + + if (hasValue(currentConfig.temperature)) + apiOptions.temperature = Number(currentConfig.temperature); + + if (hasValue(currentConfig.max_tokens)) + apiOptions.max_tokens = Number(currentConfig.max_tokens); + + if (hasValue(currentConfig.dynatemp_range)) + apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range); + + if (hasValue(currentConfig.dynatemp_exponent)) + apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent); + + if (hasValue(currentConfig.top_k)) apiOptions.top_k = Number(currentConfig.top_k); + + if (hasValue(currentConfig.top_p)) apiOptions.top_p = Number(currentConfig.top_p); + + if (hasValue(currentConfig.min_p)) apiOptions.min_p = Number(currentConfig.min_p); + + if (hasValue(currentConfig.xtc_probability)) + apiOptions.xtc_probability = Number(currentConfig.xtc_probability); + + if (hasValue(currentConfig.xtc_threshold)) + apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold); + + if (hasValue(currentConfig.typ_p)) apiOptions.typ_p = Number(currentConfig.typ_p); + + if (hasValue(currentConfig.repeat_last_n)) + apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n); + + if (hasValue(currentConfig.repeat_penalty)) + apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty); + + if (hasValue(currentConfig.presence_penalty)) + apiOptions.presence_penalty = Number(currentConfig.presence_penalty); + + if (hasValue(currentConfig.frequency_penalty)) + apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty); + + if (hasValue(currentConfig.dry_multiplier)) + apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier); + + if (hasValue(currentConfig.dry_base)) apiOptions.dry_base = Number(currentConfig.dry_base); + + if (hasValue(currentConfig.dry_allowed_length)) + apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length); + + if (hasValue(currentConfig.dry_penalty_last_n)) + apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n); + + if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers; + + apiOptions.backend_sampling = currentConfig.backend_sampling; + + if (currentConfig.custom) apiOptions.custom = currentConfig.custom; + + return apiOptions; + } + + private cancelPreEncode(): void { + if (this.preEncodeAbortController) { + this.preEncodeAbortController.abort(); + this.preEncodeAbortController = null; + } + } + + private async triggerPreEncode( + allMessages: DatabaseMessage[], + assistantMessage: DatabaseMessage, + assistantContent: string, + model?: string | null, + excludeReasoning?: boolean + ): Promise { + this.cancelPreEncode(); + this.preEncodeAbortController = new AbortController(); + + const signal = this.preEncodeAbortController.signal; + + try { + const allIdle = await ChatService.areAllSlotsIdle(model, signal); + if (!allIdle || signal.aborted) return; + + const messagesWithAssistant: DatabaseMessage[] = [ + ...allMessages, + { ...assistantMessage, content: assistantContent } + ]; + + await ChatService.preEncode(messagesWithAssistant, model, excludeReasoning, signal); + } catch (err) { + if (!isAbortError(err)) { + console.warn('[ChatStore] Pre-encode failed:', err); + } + } + } +} + +export const chatStore = new ChatStore(); + +export const activeProcessingState = () => chatStore.activeProcessingState; +export const currentResponse = () => chatStore.currentResponse; +export const errorDialog = () => chatStore.errorDialogState; +export const getAddFilesHandler = () => chatStore.getAddFilesHandler(); +export const getAllLoadingChats = () => chatStore.getAllLoadingChats(); +export const getAllStreamingChats = () => chatStore.getAllStreamingChats(); +export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId); +export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId); +export const isChatStreaming = () => chatStore.isStreaming(); +export const isEditing = () => chatStore.isEditing(); +export const isLoading = () => chatStore.isLoading; +export const pendingEditMessageId = () => chatStore.pendingEditMessageId; +export const chatHasPendingMessage = (convId: string) => chatStore.hasPendingMessage(convId); +export const chatPendingMessageContent = (convId: string) => + chatStore.pendingMessageContent(convId); +export const chatPendingMessageExtras = (convId: string) => chatStore.pendingMessageExtras(convId); +export const chatClearPendingMessage = (convId: string) => chatStore.clearPendingMessage(convId); +export const chatInjectPendingMessage = ( + convId: string, + content: string, + extras?: DatabaseMessageExtra[] +) => chatStore.injectPendingMessage(convId, content, extras); diff --git a/tools/ui/src/lib/stores/conversations.svelte.ts b/tools/ui/src/lib/stores/conversations.svelte.ts new file mode 100644 index 000000000..d6589232f --- /dev/null +++ b/tools/ui/src/lib/stores/conversations.svelte.ts @@ -0,0 +1,971 @@ +/** + * conversationsStore - Reactive State Store for Conversations + * + * Manages conversation lifecycle, persistence, navigation, and MCP server overrides. + * + * **Architecture & Relationships:** + * - **DatabaseService**: Stateless IndexedDB layer + * - **conversationsStore** (this): Reactive state + business logic + * - **chatStore**: Chat-specific state (streaming, loading) + * + * **Key Responsibilities:** + * - Conversation CRUD (create, load, delete) + * - Message management and tree navigation + * - MCP server per-chat overrides + * - Import/Export functionality + * - Title management with confirmation + * + * @see DatabaseService in services/database.ts for IndexedDB operations + */ + +import { goto } from '$app/navigation'; +import { browser } from '$app/environment'; +import { toast } from 'svelte-sonner'; +import { DatabaseService } from '$lib/services/database.service'; +import { MigrationService } from '$lib/services/migration.service'; +import { config } from '$lib/stores/settings.svelte'; +import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils'; +import type { McpServerOverride } from '$lib/types/database'; +import { MessageRole, HtmlInputType, FileExtensionText } from '$lib/enums'; +import { + ISO_DATE_TIME_SEPARATOR, + ISO_DATE_TIME_SEPARATOR_REPLACEMENT, + ISO_TIMESTAMP_SLICE_LENGTH, + EXPORT_CONV_ID_TRIM_LENGTH, + EXPORT_CONV_NONALNUM_REPLACEMENT, + EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH, + ISO_TIME_SEPARATOR, + ISO_TIME_SEPARATOR_REPLACEMENT, + NON_ALPHANUMERIC_REGEX, + MULTIPLE_UNDERSCORE_REGEX, + MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY +} from '$lib/constants'; + +import { ROUTES } from '$lib/constants/routes'; +import { RouterService } from '$lib/services/router.service'; +import { SvelteMap, SvelteSet } from 'svelte/reactivity'; + +export interface ConversationTreeItem { + conversation: DatabaseConversation; + depth: number; +} + +class ConversationsStore { + /** + * + * + * State + * + * + */ + + /** List of all conversations */ + conversations = $state([]); + + /** Currently active conversation */ + activeConversation = $state(null); + + /** Messages in the active conversation (filtered by currNode path) */ + activeMessages = $state([]); + + /** Whether the store has been initialized */ + isInitialized = $state(false); + + /** Pending MCP server overrides for new conversations (before first message) */ + pendingMcpServerOverrides = $state(ConversationsStore.loadMcpDefaults()); + + /** Load MCP default overrides from localStorage */ + private static loadMcpDefaults(): McpServerOverride[] { + if (typeof globalThis.localStorage === 'undefined') return []; + try { + const raw = localStorage.getItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (o: unknown) => typeof o === 'object' && o !== null && 'serverId' in o && 'enabled' in o + ) as McpServerOverride[]; + } catch { + return []; + } + } + + /** Persist MCP default overrides to localStorage */ + private saveMcpDefaults(): void { + if (typeof globalThis.localStorage === 'undefined') return; + const plain = this.pendingMcpServerOverrides.map((o) => ({ + serverId: o.serverId, + enabled: o.enabled + })); + if (plain.length > 0) { + localStorage.setItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY, JSON.stringify(plain)); + } else { + localStorage.removeItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY); + } + } + + /** Callback for title update confirmation dialog */ + titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise; + + /** + * Callback for updating message content in chatStore. + * Registered by chatStore to enable cross-store updates without circular dependency. + */ + private messageUpdateCallback: + | ((messageId: string, updates: Partial) => void) + | null = null; + + /** + * + * + * Lifecycle + * + * + */ + + /** + * Initialize the store by loading conversations from database. + * Must be called once after app startup. + */ + async init(): Promise { + if (!browser) return; + if (this.isInitialized) return; + + try { + await MigrationService.runAllMigrations(); + + await this.loadConversations(); + this.isInitialized = true; + } catch (error) { + console.error('Failed to initialize conversations:', error); + } + } + + /** + * Alias for init() for backward compatibility. + */ + async initialize(): Promise { + return this.init(); + } + + /** + * Register a callback for message updates from other stores. + * Called by chatStore during initialization. + */ + registerMessageUpdateCallback( + callback: (messageId: string, updates: Partial) => void + ): void { + this.messageUpdateCallback = callback; + } + + /** + * + * + * Message Array Operations + * + * + */ + + /** + * Adds a message to the active messages array + */ + addMessageToActive(message: DatabaseMessage): void { + this.activeMessages.push(message); + } + + /** + * Updates a message at a specific index in active messages + */ + updateMessageAtIndex(index: number, updates: Partial): void { + if (index !== -1 && this.activeMessages[index]) { + this.activeMessages[index] = { ...this.activeMessages[index], ...updates }; + } + } + + /** + * Finds the index of a message in active messages + */ + findMessageIndex(messageId: string): number { + return this.activeMessages.findIndex((m) => m.id === messageId); + } + + /** + * Removes messages from active messages starting at an index + */ + sliceActiveMessages(startIndex: number): void { + this.activeMessages = this.activeMessages.slice(0, startIndex); + } + + /** + * Removes a message from active messages by index + */ + removeMessageAtIndex(index: number): DatabaseMessage | undefined { + if (index !== -1) { + return this.activeMessages.splice(index, 1)[0]; + } + return undefined; + } + + /** + * Sets the callback function for title update confirmations + */ + setTitleUpdateConfirmationCallback( + callback: (currentTitle: string, newTitle: string) => Promise + ): void { + this.titleUpdateConfirmationCallback = callback; + } + + /** + * + * + * Conversation CRUD + * + * + */ + + /** + * Loads all conversations from the database + */ + async loadConversations(): Promise { + const conversations = await DatabaseService.getAllConversations(); + this.conversations = conversations; + } + + /** + * Creates a new conversation and navigates to it + * @param name - Optional name for the conversation + * @returns The ID of the created conversation + */ + async createConversation(name?: string): Promise { + const conversationName = name || `Chat ${new Date().toLocaleString()}`; + const conversation = await DatabaseService.createConversation(conversationName); + + if (this.pendingMcpServerOverrides.length > 0) { + // Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB) + const plainOverrides = this.pendingMcpServerOverrides.map((o) => ({ + serverId: o.serverId, + enabled: o.enabled + })); + conversation.mcpServerOverrides = plainOverrides; + await DatabaseService.updateConversation(conversation.id, { + mcpServerOverrides: plainOverrides + }); + this.pendingMcpServerOverrides = []; + } + + this.conversations = [conversation, ...this.conversations]; + this.activeConversation = conversation; + this.activeMessages = []; + + await goto(RouterService.chat(conversation.id)); + + return conversation.id; + } + + /** + * Loads a specific conversation and its messages + * @param convId - The conversation ID to load + * @returns True if conversation was loaded successfully + */ + async loadConversation(convId: string): Promise { + try { + const conversation = await DatabaseService.getConversation(convId); + + if (!conversation) { + return false; + } + + this.pendingMcpServerOverrides = []; + this.activeConversation = conversation; + + if (conversation.currNode) { + const allMessages = await DatabaseService.getConversationMessages(convId); + const filteredMessages = filterByLeafNodeId( + allMessages, + conversation.currNode, + false + ) as DatabaseMessage[]; + this.activeMessages = filteredMessages; + } else { + const messages = await DatabaseService.getConversationMessages(convId); + this.activeMessages = messages; + } + + return true; + } catch (error) { + console.error('Failed to load conversation:', error); + return false; + } + } + + /** + * Clears the active conversation and messages. + */ + clearActiveConversation(): void { + this.activeConversation = null; + this.activeMessages = []; + // reload MCP defaults so new chats inherit persisted state + this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults(); + } + + /** + * Deletes a conversation and all its messages + * @param convId - The conversation ID to delete + */ + async deleteConversation(convId: string, options?: { deleteWithForks?: boolean }): Promise { + try { + await DatabaseService.deleteConversation(convId, options); + + if (options?.deleteWithForks) { + // Collect all descendants recursively + const idsToRemove = new SvelteSet([convId]); + const queue = [convId]; + while (queue.length > 0) { + const parentId = queue.pop()!; + for (const c of this.conversations) { + if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) { + idsToRemove.add(c.id); + queue.push(c.id); + } + } + } + this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id)); + + if (this.activeConversation && idsToRemove.has(this.activeConversation.id)) { + this.clearActiveConversation(); + await goto(ROUTES.NEW_CHAT); + } + } else { + // Reparent direct children to deleted conv's parent (or promote to top-level) + const deletedConv = this.conversations.find((c) => c.id === convId); + const newParent = deletedConv?.forkedFromConversationId; + this.conversations = this.conversations + .filter((c) => c.id !== convId) + .map((c) => + c.forkedFromConversationId === convId + ? { ...c, forkedFromConversationId: newParent } + : c + ); + + if (this.activeConversation?.id === convId) { + this.clearActiveConversation(); + await goto(ROUTES.NEW_CHAT); + } + } + } catch (error) { + console.error('Failed to delete conversation:', error); + } + } + + /** + * Deletes all conversations and their messages + */ + async deleteAll(): Promise { + try { + const allConversations = await DatabaseService.getAllConversations(); + + for (const conv of allConversations) { + await DatabaseService.deleteConversation(conv.id); + } + + this.clearActiveConversation(); + this.conversations = []; + + toast.success('All conversations deleted'); + + await goto(ROUTES.NEW_CHAT); + } catch (error) { + console.error('Failed to delete all conversations:', error); + toast.error('Failed to delete conversations'); + } + } + + /** + * + * + * Message Management + * + * + */ + + /** + * Refreshes active messages based on currNode after branch navigation. + */ + async refreshActiveMessages(): Promise { + if (!this.activeConversation) return; + + const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id); + + if (allMessages.length === 0) { + this.activeMessages = []; + return; + } + + const leafNodeId = + this.activeConversation.currNode || + allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id; + + const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[]; + + this.activeMessages = currentPath; + } + + /** + * Gets all messages for a specific conversation + * @param convId - The conversation ID + * @returns Array of messages + */ + async getConversationMessages(convId: string): Promise { + return await DatabaseService.getConversationMessages(convId); + } + + /** + * + * + * Title Management + * + * + */ + + /** + * Updates the name of a conversation. + * @param convId - The conversation ID to update + * @param name - The new name for the conversation + */ + async updateConversationName(convId: string, name: string): Promise { + try { + await DatabaseService.updateConversation(convId, { name }); + + const convIndex = this.conversations.findIndex((c) => c.id === convId); + + if (convIndex !== -1) { + this.conversations[convIndex].name = name; + this.conversations = [...this.conversations]; + } + + if (this.activeConversation?.id === convId) { + this.activeConversation = { ...this.activeConversation, name }; + } + } catch (error) { + console.error('Failed to update conversation name:', error); + } + } + + /** + * Updates conversation title with optional confirmation dialog based on settings + * @param convId - The conversation ID to update + * @param newTitle - The new title content + * @returns True if title was updated, false if cancelled + */ + async updateConversationTitleWithConfirmation( + convId: string, + newTitle: string + ): Promise { + try { + const currentConfig = config(); + + if (currentConfig.askForTitleConfirmation && this.titleUpdateConfirmationCallback) { + const conversation = await DatabaseService.getConversation(convId); + if (!conversation) return false; + + const shouldUpdate = await this.titleUpdateConfirmationCallback( + conversation.name, + newTitle + ); + if (!shouldUpdate) return false; + } + + await this.updateConversationName(convId, newTitle); + return true; + } catch (error) { + console.error('Failed to update conversation title with confirmation:', error); + return false; + } + } + + /** + * Updates conversation lastModified timestamp and moves it to top of list + */ + updateConversationTimestamp(): void { + if (!this.activeConversation) return; + + const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); + + if (chatIndex !== -1) { + this.conversations[chatIndex].lastModified = Date.now(); + const updatedConv = this.conversations.splice(chatIndex, 1)[0]; + this.conversations = [updatedConv, ...this.conversations]; + } + } + + /** + * Updates the current node of the active conversation + * @param nodeId - The new current node ID + */ + async updateCurrentNode(nodeId: string): Promise { + if (!this.activeConversation) return; + + await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId); + this.activeConversation = { ...this.activeConversation, currNode: nodeId }; + } + + /** + * + * + * Branch Navigation + * + * + */ + + /** + * Navigates to a specific sibling branch by updating currNode and refreshing messages. + * @param siblingId - The sibling message ID to navigate to + */ + async navigateToSibling(siblingId: string): Promise { + if (!this.activeConversation) return; + + const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + const currentFirstUserMessage = this.activeMessages.find( + (m) => m.role === MessageRole.USER && m.parent === rootMessage?.id + ); + + const currentLeafNodeId = findLeafNode(allMessages, siblingId); + + await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId); + this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId }; + await this.refreshActiveMessages(); + + if (rootMessage && this.activeMessages.length > 0) { + const newFirstUserMessage = this.activeMessages.find( + (m) => m.role === MessageRole.USER && m.parent === rootMessage.id + ); + + if ( + newFirstUserMessage && + newFirstUserMessage.content.trim() && + (!currentFirstUserMessage || + newFirstUserMessage.id !== currentFirstUserMessage.id || + newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim()) + ) { + await this.updateConversationTitleWithConfirmation( + this.activeConversation.id, + generateConversationTitle( + newFirstUserMessage.content, + Boolean(config().titleGenerationUseFirstLine) + ) + ); + } + } + } + + /** + * + * + * MCP Server Overrides + * + * + */ + + /** + * Gets MCP server override for a specific server in the active conversation. + * Falls back to pending overrides if no active conversation exists. + * @param serverId - The server ID to check + * @returns The override if set, undefined if using global setting + */ + getMcpServerOverride(serverId: string): McpServerOverride | undefined { + if (this.activeConversation) { + return this.activeConversation.mcpServerOverrides?.find( + (o: McpServerOverride) => o.serverId === serverId + ); + } + return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId); + } + + /** + * Get all MCP server overrides for the current conversation. + * Returns pending overrides if no active conversation. + */ + getAllMcpServerOverrides(): McpServerOverride[] { + if (this.activeConversation?.mcpServerOverrides) { + return this.activeConversation.mcpServerOverrides; + } + return this.pendingMcpServerOverrides; + } + + /** + * Checks if an MCP server is enabled for the active conversation. + * @param serverId - The server ID to check + * @returns True if server is enabled for this conversation + */ + isMcpServerEnabledForChat(serverId: string): boolean { + const override = this.getMcpServerOverride(serverId); + return override?.enabled ?? false; + } + + /** + * Sets or removes MCP server override for the active conversation. + * If no conversation exists, stores as pending override. + * @param serverId - The server ID to override + * @param enabled - The enabled state, or undefined to remove override + */ + async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise { + if (!this.activeConversation) { + this.setPendingMcpServerOverride(serverId, enabled); + return; + } + + // Clone to plain objects to avoid Proxy serialization issues with IndexedDB + const currentOverrides = (this.activeConversation.mcpServerOverrides || []).map( + (o: McpServerOverride) => ({ + serverId: o.serverId, + enabled: o.enabled + }) + ); + let newOverrides: McpServerOverride[]; + + if (enabled === undefined) { + newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId); + } else { + const existingIndex = currentOverrides.findIndex( + (o: McpServerOverride) => o.serverId === serverId + ); + if (existingIndex >= 0) { + newOverrides = [...currentOverrides]; + newOverrides[existingIndex] = { serverId, enabled }; + } else { + newOverrides = [...currentOverrides, { serverId, enabled }]; + } + } + + await DatabaseService.updateConversation(this.activeConversation.id, { + mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined + }); + + this.activeConversation = { + ...this.activeConversation, + mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined + }; + + const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); + if (convIndex !== -1) { + this.conversations[convIndex].mcpServerOverrides = + newOverrides.length > 0 ? newOverrides : undefined; + this.conversations = [...this.conversations]; + } + } + + /** + * Sets or removes a pending MCP server override (for new conversations). + */ + private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void { + if (enabled === undefined) { + this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter( + (o) => o.serverId !== serverId + ); + } else { + const existingIndex = this.pendingMcpServerOverrides.findIndex( + (o) => o.serverId === serverId + ); + if (existingIndex >= 0) { + const newOverrides = [...this.pendingMcpServerOverrides]; + newOverrides[existingIndex] = { serverId, enabled }; + this.pendingMcpServerOverrides = newOverrides; + } else { + this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }]; + } + } + this.saveMcpDefaults(); + } + + /** + * Toggles MCP server enabled state for the active conversation. + * @param serverId - The server ID to toggle + */ + async toggleMcpServerForChat(serverId: string): Promise { + const currentEnabled = this.isMcpServerEnabledForChat(serverId); + await this.setMcpServerOverride(serverId, !currentEnabled); + } + + /** + * Removes MCP server override for the active conversation. + * @param serverId - The server ID to remove override for + */ + async removeMcpServerOverride(serverId: string): Promise { + await this.setMcpServerOverride(serverId, undefined); + } + + /** + * Clears all pending MCP server overrides. + */ + clearPendingMcpServerOverrides(): void { + this.pendingMcpServerOverrides = []; + this.saveMcpDefaults(); + } + + /** + * Forks a conversation at a specific message, creating a new conversation + * containing messages from root up to the target message, then navigates to it. + * + * @param messageId - The message ID to fork at + * @param options - Fork options (name and whether to include attachments) + * @returns The new conversation ID, or null if fork failed + */ + async forkConversation( + messageId: string, + options: { name: string; includeAttachments: boolean } + ): Promise { + if (!this.activeConversation) return null; + + try { + const newConv = await DatabaseService.forkConversation( + this.activeConversation.id, + messageId, + options + ); + + this.conversations = [newConv, ...this.conversations]; + + await goto(RouterService.chat(newConv.id)); + + toast.success('Conversation forked'); + + return newConv.id; + } catch (error) { + console.error('Failed to fork conversation:', error); + toast.error('Failed to fork conversation'); + + return null; + } + } + + /** + * + * + * Import & Export + * + * + */ + + /** + * Generates a sanitized filename for a conversation export + * @param conversation - The conversation metadata + * @param msgs - Optional array of messages belonging to the conversation + * @returns The generated filename string + */ + generateConversationFilename( + conversation: { id?: string; name?: string }, + msgs?: DatabaseMessage[] + ): string { + const conversationName = (conversation.name ?? '').trim().toLowerCase(); + + const sanitizedName = conversationName + .replace(NON_ALPHANUMERIC_REGEX, EXPORT_CONV_NONALNUM_REPLACEMENT) + .replace(MULTIPLE_UNDERSCORE_REGEX, '_') + .substring(0, EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH); + + // If we have messages, use the timestamp of the newest message + const referenceDate = msgs?.length + ? new Date(Math.max(...msgs.map((m) => m.timestamp))) + : new Date(); + + const iso = referenceDate.toISOString().slice(0, ISO_TIMESTAMP_SLICE_LENGTH); + const formattedDate = iso + .replace(ISO_DATE_TIME_SEPARATOR, ISO_DATE_TIME_SEPARATOR_REPLACEMENT) + .replaceAll(ISO_TIME_SEPARATOR, ISO_TIME_SEPARATOR_REPLACEMENT); + const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV_ID_TRIM_LENGTH) ?? ''; + return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}.json`; + } + + /** + * Triggers a browser download of the provided exported conversation data + * @param data - The exported conversation payload (either a single conversation or array of them) + * @param filename - Filename; if omitted, a deterministic name is generated + */ + downloadConversationFile(data: ExportedConversations, filename?: string): void { + // Choose the first conversation or message + const conversation = + 'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined; + const msgs = + 'messages' in data ? data.messages : Array.isArray(data) ? data[0]?.messages : undefined; + + if (!conversation) { + console.error('Invalid data: missing conversation'); + return; + } + + let downloadFilename: string; + + if (filename) { + downloadFilename = filename; + } else if (Array.isArray(data) && data.length > 1) { + downloadFilename = `${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations.json`; + } else { + downloadFilename = this.generateConversationFilename(conversation, msgs); + } + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = downloadFilename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + /** + * Downloads a conversation as JSON file. + * @param convId - The conversation ID to download + */ + async downloadConversation(convId: string): Promise { + let conversation: DatabaseConversation | null; + let messages: DatabaseMessage[]; + + if (this.activeConversation?.id === convId) { + conversation = this.activeConversation; + messages = this.activeMessages; + } else { + conversation = await DatabaseService.getConversation(convId); + if (!conversation) return; + messages = await DatabaseService.getConversationMessages(convId); + } + + this.downloadConversationFile({ conv: conversation, messages }); + } + + /** + * Imports conversations from a JSON file + * Opens file picker and processes the selected file + * @returns The list of imported conversations + */ + async importConversations(): Promise { + return new Promise((resolve, reject) => { + const input = document.createElement('input'); + input.type = HtmlInputType.FILE; + input.accept = FileExtensionText.JSON; + + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement)?.files?.[0]; + + if (!file) { + reject(new Error('No file selected')); + return; + } + + try { + const text = await file.text(); + const parsedData = JSON.parse(text); + let importedData: ExportedConversations; + + if (Array.isArray(parsedData)) { + importedData = parsedData; + } else if ( + parsedData && + typeof parsedData === 'object' && + 'conv' in parsedData && + 'messages' in parsedData + ) { + importedData = [parsedData]; + } else { + throw new Error('Invalid file format'); + } + + const result = await DatabaseService.importConversations(importedData); + toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`); + + await this.loadConversations(); + + const importedConversations = ( + Array.isArray(importedData) ? importedData : [importedData] + ).map((item) => item.conv); + + resolve(importedConversations); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error('Failed to import conversations:', err); + toast.error('Import failed', { description: message }); + reject(new Error(`Import failed: ${message}`)); + } + }; + + input.click(); + }); + } + + /** + * Imports conversations from provided data (without file picker) + * @param data - Array of conversation data with messages + * @returns Import result with counts + */ + async importConversationsData( + data: ExportedConversations + ): Promise<{ imported: number; skipped: number }> { + const result = await DatabaseService.importConversations(data); + await this.loadConversations(); + return result; + } +} + +export const conversationsStore = new ConversationsStore(); + +// Auto-initialize in browser +if (browser) { + conversationsStore.init(); +} + +export const conversations = () => conversationsStore.conversations; +export const activeConversation = () => conversationsStore.activeConversation; +export const activeMessages = () => conversationsStore.activeMessages; +export const isConversationsInitialized = () => conversationsStore.isInitialized; + +/** + * Builds a flat tree of conversations with depth levels for nested forks. + * Accepts a pre-filtered list so search filtering stays in the component. + */ +export function buildConversationTree(convs: DatabaseConversation[]): ConversationTreeItem[] { + const childrenByParent = new SvelteMap(); + const forkIds = new SvelteSet(); + + for (const conv of convs) { + if (conv.forkedFromConversationId) { + forkIds.add(conv.id); + + const siblings = childrenByParent.get(conv.forkedFromConversationId) || []; + + siblings.push(conv); + childrenByParent.set(conv.forkedFromConversationId, siblings); + } + } + + const result: ConversationTreeItem[] = []; + const visited = new SvelteSet(); + + function walk(conv: DatabaseConversation, depth: number) { + visited.add(conv.id); + result.push({ conversation: conv, depth }); + + const children = childrenByParent.get(conv.id); + if (children) { + children.sort((a, b) => b.lastModified - a.lastModified); + + for (const child of children) { + walk(child, depth + 1); + } + } + } + + const roots = convs.filter((c) => !forkIds.has(c.id)); + for (const root of roots) { + walk(root, 0); + } + + for (const conv of convs) { + if (!visited.has(conv.id)) { + walk(conv, 1); + } + } + + return result; +} diff --git a/tools/ui/src/lib/stores/draft-messages.svelte.ts b/tools/ui/src/lib/stores/draft-messages.svelte.ts new file mode 100644 index 000000000..7ee814d84 --- /dev/null +++ b/tools/ui/src/lib/stores/draft-messages.svelte.ts @@ -0,0 +1,31 @@ +import { NEW_CHAT_DRAFT_KEY } from '$lib/constants'; + +interface DraftMessage { + message: string; + files: ChatUploadedFile[]; +} + +class DraftMessagesStore { + private drafts = new Map(); + + getDraftMessage(chatId: string | undefined): DraftMessage { + const key = chatId ?? NEW_CHAT_DRAFT_KEY; + return this.drafts.get(key) ?? { message: '', files: [] }; + } + + saveDraftMessage(chatId: string | undefined, message: string, files: ChatUploadedFile[]): void { + const key = chatId ?? NEW_CHAT_DRAFT_KEY; + if (message || files.length > 0) { + this.drafts.set(key, { message, files: [...files] }); + } else { + this.drafts.delete(key); + } + } + + clearDraftMessage(chatId: string | undefined): void { + const key = chatId ?? NEW_CHAT_DRAFT_KEY; + this.drafts.delete(key); + } +} + +export const draftMessagesStore = new DraftMessagesStore(); diff --git a/tools/ui/src/lib/stores/mcp-resources.svelte.ts b/tools/ui/src/lib/stores/mcp-resources.svelte.ts new file mode 100644 index 000000000..18347fb75 --- /dev/null +++ b/tools/ui/src/lib/stores/mcp-resources.svelte.ts @@ -0,0 +1,608 @@ +/** + * mcpResourceStore - Reactive State Store for MCP Resources + * + * Manages MCP protocol resources: + * - Resource discovery and listing per server + * - Resource content caching + * - Resource subscriptions + * - Resource attachments for chat context + * + * @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18/server/resources + */ + +import { SvelteMap } from 'svelte/reactivity'; +import { AttachmentType } from '$lib/enums'; +import { + MCP_RESOURCE_ATTACHMENT_ID_PREFIX, + MCP_RESOURCE_CACHE_MAX_ENTRIES, + MCP_RESOURCE_CACHE_TTL_MS, + NEWLINE_SEPARATOR, + RESOURCE_UNKNOWN_TYPE, + BINARY_CONTENT_LABEL +} from '$lib/constants'; +import { normalizeResourceUri } from '$lib/utils'; +import type { + MCPResource, + MCPResourceTemplate, + MCPResourceContent, + MCPResourceInfo, + MCPResourceTemplateInfo, + MCPCachedResource, + MCPResourceAttachment, + MCPResourceSubscription, + MCPServerResources, + DatabaseMessageExtraMcpResource +} from '$lib/types'; + +function generateAttachmentId(): string { + return `${MCP_RESOURCE_ATTACHMENT_ID_PREFIX}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} + +class MCPResourceStore { + private _serverResources = $state>(new SvelteMap()); + private _cachedResources = $state>(new SvelteMap()); + private _subscriptions = $state>(new SvelteMap()); + private _attachments = $state([]); + private _isLoading = $state(false); + + get serverResources(): Map { + return this._serverResources; + } + + get cachedResources(): Map { + return this._cachedResources; + } + + get subscriptions(): Map { + return this._subscriptions; + } + + get attachments(): MCPResourceAttachment[] { + return this._attachments; + } + + get isLoading(): boolean { + return this._isLoading; + } + + get totalResourceCount(): number { + let count = 0; + for (const serverRes of this._serverResources.values()) { + count += serverRes.resources.length; + } + + return count; + } + + get totalTemplateCount(): number { + let count = 0; + for (const serverRes of this._serverResources.values()) { + count += serverRes.templates.length; + } + + return count; + } + + get attachmentCount(): number { + return this._attachments.length; + } + + get hasAttachments(): boolean { + return this._attachments.length > 0; + } + + /** + * + * + * Server Resources Management + * + * + */ + + /** + * Set resources for a server (called after listResources) + */ + setServerResources( + serverName: string, + resources: MCPResource[], + templates: MCPResourceTemplate[] + ): void { + this._serverResources.set(serverName, { + serverName, + resources, + templates, + lastFetched: new Date(), + loading: false, + error: undefined + }); + console.log( + `[MCPResources][${serverName}] Set ${resources.length} resources, ${templates.length} templates` + ); + } + + /** + * Set loading state for a server's resources + */ + setServerLoading(serverName: string, loading: boolean): void { + const existing = this._serverResources.get(serverName); + if (existing) { + this._serverResources.set(serverName, { ...existing, loading }); + } else { + this._serverResources.set(serverName, { + serverName, + resources: [], + templates: [], + loading, + error: undefined + }); + } + } + + /** + * Set error state for a server's resources + */ + setServerError(serverName: string, error: string): void { + const existing = this._serverResources.get(serverName); + + if (existing) { + this._serverResources.set(serverName, { ...existing, loading: false, error }); + } else { + this._serverResources.set(serverName, { + serverName, + resources: [], + templates: [], + loading: false, + error + }); + } + } + + /** + * Get resources for a specific server + */ + getServerResources(serverName: string): MCPServerResources | undefined { + return this._serverResources.get(serverName); + } + + /** + * Get all resources as MCPResourceInfo array (flattened with server names) + */ + getAllResourceInfos(): MCPResourceInfo[] { + const result: MCPResourceInfo[] = []; + + for (const [serverName, serverRes] of this._serverResources) { + for (const resource of serverRes.resources) { + result.push({ + uri: resource.uri, + name: resource.name, + title: resource.title, + description: resource.description, + mimeType: resource.mimeType, + serverName, + annotations: resource.annotations, + icons: resource.icons + }); + } + } + + return result; + } + + /** + * Get all templates as MCPResourceTemplateInfo array (flattened with server names) + */ + getAllTemplateInfos(): MCPResourceTemplateInfo[] { + const result: MCPResourceTemplateInfo[] = []; + + for (const [serverName, serverRes] of this._serverResources) { + for (const template of serverRes.templates) { + result.push({ + uriTemplate: template.uriTemplate, + name: template.name, + title: template.title, + description: template.description, + mimeType: template.mimeType, + serverName, + annotations: template.annotations, + icons: template.icons + }); + } + } + + return result; + } + + /** + * Clear resources for a server (e.g., when disconnected) + */ + clearServerResources(serverName: string): void { + this._serverResources.delete(serverName); + + // Also clear cached content for this server's resources + for (const [uri, cached] of this._cachedResources) { + if (cached.resource.serverName === serverName) { + this._cachedResources.delete(uri); + } + } + + // Clear subscriptions for this server + for (const [uri, sub] of this._subscriptions) { + if (sub.serverName === serverName) { + this._subscriptions.delete(uri); + } + } + + console.log(`[MCPResources][${serverName}] Cleared all resources`); + } + + /** + * + * + * Resource Content Caching + * + * + */ + + /** + * Cache resource content after reading + */ + cacheResourceContent(resource: MCPResourceInfo, content: MCPResourceContent[]): void { + // Enforce cache size limit + if (this._cachedResources.size >= MCP_RESOURCE_CACHE_MAX_ENTRIES) { + // Remove oldest entry + const oldestKey = this._cachedResources.keys().next().value; + + if (oldestKey) { + this._cachedResources.delete(oldestKey); + } + } + + this._cachedResources.set(resource.uri, { + resource, + content, + fetchedAt: new Date(), + subscribed: this._subscriptions.has(resource.uri) + }); + console.log(`[MCPResources] Cached content for: ${resource.uri}`); + } + + /** + * Get cached content for a resource + */ + getCachedContent(uri: string): MCPCachedResource | undefined { + const cached = this._cachedResources.get(uri); + if (!cached) return undefined; + + // Check if cache is still valid + const age = Date.now() - cached.fetchedAt.getTime(); + + if (age > MCP_RESOURCE_CACHE_TTL_MS && !cached.subscribed) { + // Cache expired and not subscribed, remove it + this._cachedResources.delete(uri); + + return undefined; + } + + return cached; + } + + /** + * Invalidate cached content for a resource (e.g., on update notification) + */ + invalidateCache(uri: string): void { + this._cachedResources.delete(uri); + console.log(`[MCPResources] Invalidated cache for: ${uri}`); + } + + /** + * Clear all cached content + */ + clearCache(): void { + this._cachedResources.clear(); + console.log(`[MCPResources] Cleared all cached content`); + } + + /** + * + * + * Subscriptions + * + * + */ + + /** + * Register a subscription for a resource + */ + addSubscription(uri: string, serverName: string): void { + this._subscriptions.set(uri, { + uri, + serverName, + subscribedAt: new Date() + }); + + // Update cached resource if exists + const cached = this._cachedResources.get(uri); + if (cached) { + this._cachedResources.set(uri, { ...cached, subscribed: true }); + } + + console.log(`[MCPResources] Added subscription: ${uri}`); + } + + /** + * Remove a subscription for a resource + */ + removeSubscription(uri: string): void { + this._subscriptions.delete(uri); + + // Update cached resource if exists + const cached = this._cachedResources.get(uri); + if (cached) { + this._cachedResources.set(uri, { ...cached, subscribed: false }); + } + + console.log(`[MCPResources] Removed subscription: ${uri}`); + } + + /** + * Check if a resource is subscribed + */ + isSubscribed(uri: string): boolean { + return this._subscriptions.has(uri); + } + + /** + * Handle resource update notification + */ + handleResourceUpdate(uri: string): void { + // Invalidate cache so next read gets fresh content + this.invalidateCache(uri); + + // Update subscription last update time + const sub = this._subscriptions.get(uri); + if (sub) { + this._subscriptions.set(uri, { ...sub, lastUpdate: new Date() }); + } + + console.log(`[MCPResources] Resource updated: ${uri}`); + } + + /** + * Handle resources list changed notification + */ + handleResourcesListChanged(serverName: string): void { + // Mark server resources as needing refresh + const existing = this._serverResources.get(serverName); + if (existing) { + this._serverResources.set(serverName, { + ...existing, + lastFetched: undefined // Mark as stale + }); + } + console.log(`[MCPResources][${serverName}] Resources list changed, needs refresh`); + } + + /** + * + * + * Attachments (for chat context) + * + * + */ + + /** + * Add a resource attachment to the current chat context + */ + addAttachment(resource: MCPResourceInfo): MCPResourceAttachment { + const attachment: MCPResourceAttachment = { + id: generateAttachmentId(), + resource, + loading: true + }; + + this._attachments = [...this._attachments, attachment]; + console.log(`[MCPResources] Added attachment: ${resource.uri}`); + + return attachment; + } + + /** + * Update attachment with fetched content + */ + updateAttachmentContent(attachmentId: string, content: MCPResourceContent[]): void { + this._attachments = this._attachments.map((att) => + att.id === attachmentId ? { ...att, content, loading: false, error: undefined } : att + ); + } + + /** + * Update attachment with error + */ + updateAttachmentError(attachmentId: string, error: string): void { + this._attachments = this._attachments.map((att) => + att.id === attachmentId ? { ...att, loading: false, error } : att + ); + } + + /** + * Remove an attachment + */ + removeAttachment(attachmentId: string): void { + this._attachments = this._attachments.filter((att) => att.id !== attachmentId); + console.log(`[MCPResources] Removed attachment: ${attachmentId}`); + } + + /** + * Clear all attachments + */ + clearAttachments(): void { + this._attachments = []; + console.log(`[MCPResources] Cleared all attachments`); + } + + /** + * Get attachment by ID + */ + getAttachment(attachmentId: string): MCPResourceAttachment | undefined { + return this._attachments.find((att) => att.id === attachmentId); + } + + /** + * Check if a resource is already attached + */ + isAttached(uri: string): boolean { + const normalizedUri = normalizeResourceUri(uri); + + return this._attachments.some( + (att) => att.resource.uri === uri || normalizeResourceUri(att.resource.uri) === normalizedUri + ); + } + + /** + * + * + * Utility Methods + * + * + */ + + /** + * Set global loading state + */ + setLoading(loading: boolean): void { + this._isLoading = loading; + } + + /** + * Find resource info by URI across all servers + */ + findResourceByUri(uri: string): MCPResourceInfo | undefined { + const normalizedUri = normalizeResourceUri(uri); + + for (const [serverName, serverRes] of this._serverResources) { + const resource = + serverRes.resources.find((r) => r.uri === uri) ?? + serverRes.resources.find((r) => normalizeResourceUri(r.uri) === normalizedUri); + + if (resource) { + return { + uri: resource.uri, + name: resource.name, + title: resource.title, + description: resource.description, + mimeType: resource.mimeType, + serverName, + annotations: resource.annotations, + icons: resource.icons + }; + } + } + + return undefined; + } + + /** + * Find server name for a resource URI + */ + findServerForUri(uri: string): string | undefined { + for (const [serverName, serverRes] of this._serverResources) { + if (serverRes.resources.some((r) => r.uri === uri)) { + return serverName; + } + } + + return undefined; + } + + /** + * Clear all state (e.g., on full reset) + */ + clear(): void { + this._serverResources.clear(); + this._cachedResources.clear(); + this._subscriptions.clear(); + this._attachments = []; + this._isLoading = false; + console.log(`[MCPResources] Cleared all state`); + } + + /** + * Get resource content as text for chat context + * Formats content for inclusion in LLM prompts + */ + formatAttachmentsForContext(): string { + if (this._attachments.length === 0) return ''; + + const parts: string[] = []; + + for (const attachment of this._attachments) { + if (attachment.error) continue; + if (!attachment.content || attachment.content.length === 0) continue; + + const resourceName = attachment.resource.title || attachment.resource.name; + const serverName = attachment.resource.serverName; + + for (const content of attachment.content) { + if ('text' in content && content.text) { + parts.push(`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n${content.text}`); + } else if ('blob' in content && content.blob) { + // For binary content, just note it exists + parts.push( + `\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]` + ); + } + } + } + + return parts.join(''); + } + + /** + * Convert current resource attachments to DatabaseMessageExtra[] for persisting with a message. + * Each attachment becomes a DatabaseMessageExtraMcpResource stored on the user message. + */ + toMessageExtras(): DatabaseMessageExtraMcpResource[] { + const extras: DatabaseMessageExtraMcpResource[] = []; + + for (const attachment of this._attachments) { + if (attachment.error) continue; + if (!attachment.content || attachment.content.length === 0) continue; + + const resourceName = attachment.resource.title || attachment.resource.name; + const contentParts: string[] = []; + + for (const content of attachment.content) { + if ('text' in content && content.text) { + contentParts.push(content.text); + } else if ('blob' in content && content.blob) { + contentParts.push( + `[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]` + ); + } + } + + if (contentParts.length > 0) { + extras.push({ + type: AttachmentType.MCP_RESOURCE, + name: resourceName, + uri: attachment.resource.uri, + serverName: attachment.resource.serverName, + content: contentParts.join(NEWLINE_SEPARATOR), + mimeType: attachment.resource.mimeType + }); + } + } + + return extras; + } +} + +export const mcpResourceStore = new MCPResourceStore(); + +// Export convenience functions +export const mcpResources = () => mcpResourceStore.serverResources; +export const mcpResourceAttachments = () => mcpResourceStore.attachments; +export const mcpResourceAttachmentCount = () => mcpResourceStore.attachmentCount; +export const mcpHasResourceAttachments = () => mcpResourceStore.hasAttachments; +export const mcpTotalResourceCount = () => mcpResourceStore.totalResourceCount; +export const mcpResourcesLoading = () => mcpResourceStore.isLoading; diff --git a/tools/ui/src/lib/stores/mcp.svelte.ts b/tools/ui/src/lib/stores/mcp.svelte.ts new file mode 100644 index 000000000..2a1eb3ff5 --- /dev/null +++ b/tools/ui/src/lib/stores/mcp.svelte.ts @@ -0,0 +1,1977 @@ +/** + * mcpStore - Reactive State Store for MCP Operations + * + * Implements the "Host" role in MCP architecture, coordinating multiple server + * connections and providing a unified interface for tool operations. + * + * **Architecture & Relationships:** + * - **MCPService**: Stateless protocol layer (transport, connect, callTool) + * - **mcpStore** (this): Reactive state + business logic + * + * **Key Responsibilities:** + * - Lifecycle management (initialize, shutdown) + * - Multi-server coordination + * - Tool name conflict detection and resolution + * - OpenAI-compatible tool definition generation + * - Automatic tool-to-server routing + * - Health checks + * + * @see MCPService in services/mcp.service.ts for protocol operations + */ + +import { browser } from '$app/environment'; +import { base } from '$app/paths'; +import { SETTINGS_KEYS } from '$lib/constants'; +import { MCPService } from '$lib/services/mcp.service'; +import { config, settingsStore } from '$lib/stores/settings.svelte'; +import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte'; +import { mode } from 'mode-watcher'; +import { + parseMcpServerSettings, + detectMcpTransportFromUrl, + uuid, + extractRootDomain +} from '$lib/utils'; +import { + MCPConnectionPhase, + MCPLogLevel, + HealthCheckStatus, + MCPRefType, + ColorMode, + UrlProtocol, + JsonSchemaType, + ToolCallType +} from '$lib/enums'; +import { + CORS_PROXY_ENDPOINT, + DEFAULT_CACHE_TTL_MS, + DEFAULT_MCP_CONFIG, + EXPECTED_THEMED_ICON_PAIR_COUNT, + MCP_ALLOWED_ICON_MIME_TYPES, + MCP_SERVER_ID_PREFIX, + MCP_RECONNECT_INITIAL_DELAY, + MCP_RECONNECT_BACKOFF_MULTIPLIER, + MCP_RECONNECT_MAX_DELAY, + MCP_RECONNECT_ATTEMPT_TIMEOUT_MS +} from '$lib/constants'; +import type { + MCPToolCall, + OpenAIToolDefinition, + ServerStatus, + ToolExecutionResult, + MCPClientConfig, + MCPConnection, + HealthCheckParams, + ServerCapabilities, + ClientCapabilities, + MCPCapabilitiesInfo, + MCPConnectionLog, + MCPPromptInfo, + GetPromptResult, + Tool, + HealthCheckState, + MCPServerSettingsEntry, + MCPServerConfig, + MCPResourceIcon, + MCPResourceAttachment, + MCPResourceContent +} from '$lib/types'; +import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js'; +import type { DatabaseMessageExtraMcpResource, McpServerOverride } from '$lib/types/database'; +import type { SettingsConfigType } from '$lib/types/settings'; + +class MCPStore { + private _isInitializing = $state(false); + private _error = $state(null); + private _toolCount = $state(0); + private _connectedServers = $state([]); + private _healthChecks = $state>({}); + private _proxyAvailable = $state(false); + + private connections = new Map(); + private toolsIndex = new Map(); + private serverConfigs = new Map(); // Store configs for reconnection + private reconnectingServers = new Set(); // Guard against concurrent reconnections + private configSignature: string | null = null; + private initPromise: Promise | null = null; + private activeFlowCount = 0; + + constructor() { + if (browser) { + this.probeProxy(); + } + } + + /** + * Probes the CORS proxy endpoint to determine availability. + * The endpoint is only registered when llama-server runs with --ui-mcp-proxy. + */ + async probeProxy(): Promise { + try { + const response = await fetch(`${base}${CORS_PROXY_ENDPOINT}`, { method: 'HEAD' }); + this._proxyAvailable = response.status !== 404; + } catch { + this._proxyAvailable = false; + } + } + + get isProxyAvailable(): boolean { + return this._proxyAvailable; + } + + /** + * Generates a unique server ID from an optional ID string or index. + */ + #generateServerId(id: unknown, index: number): string { + if (typeof id === 'string' && id.trim()) { + return id.trim(); + } + + return `${MCP_SERVER_ID_PREFIX}-${index + 1}`; + } + + /** + * Parses raw server settings from config into MCPServerSettingsEntry array. + */ + #parseServerSettings(rawServers: unknown): MCPServerSettingsEntry[] { + if (!rawServers) { + return []; + } + + let parsed: unknown; + if (typeof rawServers === 'string') { + const trimmed = rawServers.trim(); + if (!trimmed) { + return []; + } + + try { + parsed = JSON.parse(trimmed); + } catch (error) { + console.warn('[MCP] Failed to parse mcpServers JSON:', error); + + return []; + } + } else { + parsed = rawServers; + } + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.map((entry, index) => { + const url = typeof entry?.url === 'string' ? entry.url.trim() : ''; + const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined; + + return { + id: this.#generateServerId((entry as { id?: unknown })?.id, index), + enabled: Boolean((entry as { enabled?: unknown })?.enabled), + url, + name: (entry as { name?: string })?.name, + requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds, + headers: headers || undefined, + useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy) + } satisfies MCPServerSettingsEntry; + }); + } + + /** + * Builds server configuration from a settings entry. + */ + #buildServerConfig( + entry: MCPServerSettingsEntry, + connectionTimeoutMs = DEFAULT_MCP_CONFIG.connectionTimeoutMs + ): MCPServerConfig | undefined { + if (!entry?.url) { + return undefined; + } + + let headers: Record | undefined; + if (entry.headers) { + try { + const parsed = JSON.parse(entry.headers); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) + headers = parsed as Record; + } catch { + console.warn('[MCP] Failed to parse custom headers JSON:', entry.headers); + } + } + + return { + url: entry.url, + transport: detectMcpTransportFromUrl(entry.url), + handshakeTimeoutMs: connectionTimeoutMs, + requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000), + headers, + useProxy: entry.useProxy + }; + } + + /** + * Checks if a server is enabled for a given chat. + * Only per-chat overrides (persisted in localStorage for new chats, + * or in IndexedDB for existing conversations) control enabled state. + */ + #checkServerEnabled( + server: MCPServerSettingsEntry, + perChatOverrides?: McpServerOverride[] + ): boolean { + const override = perChatOverrides?.find((o) => o.serverId === server.id); + return override?.enabled ?? false; + } + + /** + * Builds MCP client configuration from settings. + */ + #buildMcpClientConfig( + cfg: SettingsConfigType, + perChatOverrides?: McpServerOverride[] + ): MCPClientConfig | undefined { + const rawServers = this.#parseServerSettings(cfg.mcpServers); + if (!rawServers.length) { + return undefined; + } + + const servers: Record = {}; + + for (const [index, entry] of rawServers.entries()) { + if (!this.#checkServerEnabled(entry, perChatOverrides)) continue; + const normalized = this.#buildServerConfig(entry); + if (normalized) servers[this.#generateServerId(entry.id, index)] = normalized; + } + + if (Object.keys(servers).length === 0) { + return undefined; + } + + return { + protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion, + capabilities: DEFAULT_MCP_CONFIG.capabilities, + clientInfo: DEFAULT_MCP_CONFIG.clientInfo, + requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000), + servers + }; + } + + /** + * Builds capabilities info from server and client capabilities. + */ + #buildCapabilitiesInfo( + serverCaps?: ServerCapabilities, + clientCaps?: ClientCapabilities + ): MCPCapabilitiesInfo { + return { + server: { + tools: serverCaps?.tools ? { listChanged: serverCaps.tools.listChanged } : undefined, + prompts: serverCaps?.prompts ? { listChanged: serverCaps.prompts.listChanged } : undefined, + resources: serverCaps?.resources + ? { + subscribe: serverCaps.resources.subscribe, + listChanged: serverCaps.resources.listChanged + } + : undefined, + logging: !!serverCaps?.logging, + completions: !!serverCaps?.completions, + tasks: !!serverCaps?.tasks + }, + client: { + roots: clientCaps?.roots ? { listChanged: clientCaps.roots.listChanged } : undefined, + sampling: !!clientCaps?.sampling, + elicitation: clientCaps?.elicitation + ? { form: !!clientCaps.elicitation.form, url: !!clientCaps.elicitation.url } + : undefined, + tasks: !!clientCaps?.tasks + } + }; + } + + get isInitializing(): boolean { + return this._isInitializing; + } + + get isInitialized(): boolean { + return this.connections.size > 0; + } + + get error(): string | null { + return this._error; + } + + get toolCount(): number { + return this._toolCount; + } + + get connectedServerCount(): number { + return this._connectedServers.length; + } + + get connectedServerNames(): string[] { + return this._connectedServers; + } + + get isEnabled(): boolean { + const mcpConfig = this.#buildMcpClientConfig(config()); + return ( + mcpConfig !== null && mcpConfig !== undefined && Object.keys(mcpConfig.servers).length > 0 + ); + } + + get availableTools(): string[] { + return Array.from(this.toolsIndex.keys()); + } + + private updateState(state: { + isInitializing?: boolean; + error?: string | null; + toolCount?: number; + connectedServers?: string[]; + }): void { + if (state.isInitializing !== undefined) { + this._isInitializing = state.isInitializing; + } + + if (state.error !== undefined) { + this._error = state.error; + } + + if (state.toolCount !== undefined) { + this._toolCount = state.toolCount; + } + + if (state.connectedServers !== undefined) { + this._connectedServers = state.connectedServers; + } + } + + updateHealthCheck(serverId: string, state: HealthCheckState): void { + this._healthChecks = { ...this._healthChecks, [serverId]: state }; + } + + getHealthCheckState(serverId: string): HealthCheckState { + return this._healthChecks[serverId] ?? { status: HealthCheckStatus.IDLE }; + } + + hasHealthCheck(serverId: string): boolean { + return ( + serverId in this._healthChecks && + this._healthChecks[serverId].status !== HealthCheckStatus.IDLE + ); + } + + clearHealthCheck(serverId: string): void { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [serverId]: _removed, ...rest } = this._healthChecks; + this._healthChecks = rest; + } + + clearAllHealthChecks(): void { + this._healthChecks = {}; + } + + clearError(): void { + this._error = null; + } + + getServers(): MCPServerSettingsEntry[] { + return parseMcpServerSettings(config().mcpServers); + } + + /** + * Get all active MCP connections. + * @returns Map of server names to connections + */ + getConnections(): Map { + return this.connections; + } + + getServerLabel(server: MCPServerSettingsEntry): string { + const healthState = this.getHealthCheckState(server.id); + + if (healthState?.status === HealthCheckStatus.SUCCESS) + return ( + healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url + ); + return server.url; + } + + getServerById(serverId: string): MCPServerSettingsEntry | undefined { + return this.getServers().find((s) => s.id === serverId); + } + + /** + * Get display name for an MCP server by its ID. + * Falls back to the server ID if server is not found. + */ + getServerDisplayName(serverId: string): string { + const server = this.getServerById(serverId); + return server ? this.getServerLabel(server) : serverId; + } + + /** + * Validates that an icon URI uses a safe scheme (https: or data:). + */ + #isValidIconUri(src: string): boolean { + try { + if (src.startsWith(UrlProtocol.DATA)) return true; + + const url = new URL(src); + + return url.protocol === UrlProtocol.HTTPS; + } catch { + return false; + } + } + + /** + * Selects the best icon URL from an MCP icons array. + * Follows security guidelines from the MCP specification: + * - Only allows https: and data: URIs + * - Filters to supported MIME types + * + * Selection priority: + * 1. Icon matching the current color scheme (dark/light) + * 2. Universal icon (no theme specified); if exactly 2, assumes [0]=light, [1]=dark + * 3. First valid icon as last resort + */ + #getMcpIconUrl(icons: MCPResourceIcon[] | undefined, isDark = false): string | null { + if (!icons?.length) return null; + + const validIcons = icons.filter((icon) => { + if (!icon.src || !this.#isValidIconUri(icon.src)) return false; + if (icon.mimeType && !MCP_ALLOWED_ICON_MIME_TYPES.has(icon.mimeType)) return false; + return true; + }); + + if (validIcons.length === 0) return null; + + const preferredTheme = isDark ? ColorMode.DARK : ColorMode.LIGHT; + + // 1. Prefer icon explicitly matching the current color scheme + const themedIcon = validIcons.find((icon) => icon.theme === preferredTheme); + if (themedIcon) return themedIcon.src; + + // 2. Handle universal icons (no theme specified) + const universalIcons = validIcons.filter((icon) => !icon.theme); + + if (universalIcons.length === EXPECTED_THEMED_ICON_PAIR_COUNT) { + // Heuristic: two theme-less icons → assume [0] = light, [1] = dark + return universalIcons[isDark ? 1 : 0].src; + } + + if (universalIcons.length > 0) { + return universalIcons[0].src; + } + + // 3. Last resort: use opposite-theme icon + return validIcons[0].src; + } + + /** + * Get icon URL for an MCP server by its ID. + * Returns the best icon from the MCP server's `icons` array + * (see MCP spec: spec.modelcontextprotocol.io). + * Returns null if no icon is available. + */ + getServerFavicon(serverId: string): string | null { + const server = this.getServerById(serverId); + if (!server) { + return null; + } + + const isDark = mode.current === ColorMode.DARK; + const healthState = this.getHealthCheckState(serverId); + if (healthState.status === HealthCheckStatus.SUCCESS && healthState.serverInfo?.icons) { + const mcpIconUrl = this.#getMcpIconUrl(healthState.serverInfo.icons, isDark); + + if (mcpIconUrl) { + return mcpIconUrl; + } + } + + // Fallback: try favicon from root domain + const fallbackUrl = this.#getServerFaviconFallback(server.url); + if (fallbackUrl) { + return fallbackUrl; + } + + return null; + } + + /** + * Construct a fallback favicon URL from the MCP server URL. + * e.g. https://mcp.exa.ai/mcp -> https://exa.ai/favicon.ico + */ + #getServerFaviconFallback(serverUrl: string): string | null { + try { + const url = new URL(serverUrl); + const rootDomain = extractRootDomain(url); + if (!rootDomain) return null; + + const origin = `${url.protocol}//${rootDomain}`; + const candidates = ['favicon.ico', 'favicon.svg', 'favicon.png']; + + for (const path of candidates) { + const faviconUrl = `${origin}/${path}`; + if (this.#isValidIconUri(faviconUrl)) { + return faviconUrl; + } + } + } catch { + // Invalid URL, return null + } + + return null; + } + + isAnyServerLoading(): boolean { + return this.getServers().some((s) => { + const state = this.getHealthCheckState(s.id); + + return ( + state.status === HealthCheckStatus.IDLE || state.status === HealthCheckStatus.CONNECTING + ); + }); + } + + getServersSorted(): MCPServerSettingsEntry[] { + const servers = this.getServers(); + if (this.isAnyServerLoading()) { + return servers; + } + + return [...servers].sort((a, b) => + this.getServerLabel(a).localeCompare(this.getServerLabel(b)) + ); + } + + addServer( + serverData: Omit & { id?: string } + ): void { + const servers = this.getServers(); + const newServer: MCPServerSettingsEntry = { + id: serverData.id || (uuid() ?? `server-${Date.now()}`), + enabled: serverData.enabled, + url: serverData.url.trim(), + name: serverData.name, + headers: serverData.headers?.trim() || undefined, + requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds, + useProxy: serverData.useProxy + }; + settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, JSON.stringify([...servers, newServer])); + } + + updateServer(id: string, updates: Partial): void { + const servers = this.getServers(); + settingsStore.updateConfig( + SETTINGS_KEYS.MCP_SERVERS, + JSON.stringify( + servers.map((server) => (server.id === id ? { ...server, ...updates } : server)) + ) + ); + } + + removeServer(id: string): void { + const servers = this.getServers(); + settingsStore.updateConfig( + SETTINGS_KEYS.MCP_SERVERS, + JSON.stringify(servers.filter((s) => s.id !== id)) + ); + this.clearHealthCheck(id); + } + + hasAvailableServers(): boolean { + return parseMcpServerSettings(config().mcpServers).some((s) => s.enabled && s.url.trim()); + } + hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean { + return Boolean(this.#buildMcpClientConfig(config(), perChatOverrides)); + } + + getEnabledServersForConversation( + perChatOverrides?: McpServerOverride[] + ): MCPServerSettingsEntry[] { + return this.getServers().filter((server) => { + return this.#checkServerEnabled(server, perChatOverrides); + }); + } + + async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise { + if (!browser) { + return false; + } + + const mcpConfig = this.#buildMcpClientConfig(config(), perChatOverrides); + const signature = mcpConfig ? JSON.stringify(mcpConfig) : null; + if (!signature) { + await this.shutdown(); + + return false; + } + if (this.isInitialized && this.configSignature === signature) { + return true; + } + + if (this.initPromise && this.configSignature === signature) { + return this.initPromise; + } + + if (this.connections.size > 0 || this.initPromise) await this.shutdown(); + return this.initialize(signature, mcpConfig!); + } + + private async initialize(signature: string, mcpConfig: MCPClientConfig): Promise { + this.updateState({ isInitializing: true, error: null }); + this.configSignature = signature; + + const serverEntries = Object.entries(mcpConfig.servers); + + if (serverEntries.length === 0) { + this.updateState({ isInitializing: false, toolCount: 0, connectedServers: [] }); + + return false; + } + this.initPromise = this.doInitialize(signature, mcpConfig, serverEntries); + + return this.initPromise; + } + + private async doInitialize( + signature: string, + mcpConfig: MCPClientConfig, + serverEntries: [string, MCPClientConfig['servers'][string]][] + ): Promise { + const clientInfo = mcpConfig.clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo; + const capabilities = mcpConfig.capabilities ?? DEFAULT_MCP_CONFIG.capabilities; + const results = await Promise.allSettled( + serverEntries.map(async ([name, serverConfig]) => { + // Store config for reconnection + this.serverConfigs.set(name, serverConfig); + + const listChangedHandlers = this.createListChangedHandlers(name); + const connection = await MCPService.connect( + name, + serverConfig, + clientInfo, + capabilities, + (phase) => { + // Handle WebSocket disconnection + if (phase === MCPConnectionPhase.DISCONNECTED) { + console.log(`[MCPStore][${name}] Connection lost, starting auto-reconnect`); + this.autoReconnect(name); + } + }, + listChangedHandlers + ); + + return { name, connection }; + }) + ); + if (this.configSignature !== signature) { + for (const result of results) { + if (result.status === 'fulfilled') + await MCPService.disconnect(result.value.connection).catch(console.warn); + } + + return false; + } + for (const result of results) { + if (result.status === 'fulfilled') { + const { name, connection } = result.value; + + this.connections.set(name, connection); + + for (const tool of connection.tools) { + if (this.toolsIndex.has(tool.name)) + console.warn( + `[MCPStore] Tool name conflict: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${name}". Using tool from "${name}".` + ); + this.toolsIndex.set(tool.name, name); + } + } else { + console.error(`[MCPStore] Failed to connect:`, result.reason); + } + } + + const successCount = this.connections.size; + if (successCount === 0 && serverEntries.length > 0) { + this.updateState({ + isInitializing: false, + error: 'All MCP server connections failed', + toolCount: 0, + connectedServers: [] + }); + this.initPromise = null; + + return false; + } + + this.updateState({ + isInitializing: false, + error: null, + toolCount: this.toolsIndex.size, + connectedServers: Array.from(this.connections.keys()) + }); + this.initPromise = null; + + return true; + } + + private createListChangedHandlers(serverName: string): ListChangedHandlers { + return { + tools: { + onChanged: (error: Error | null, tools: Tool[] | null) => { + if (error) { + console.warn(`[MCPStore][${serverName}] Tools list changed error:`, error); + return; + } + this.handleToolsListChanged(serverName, tools ?? []); + } + }, + prompts: { + onChanged: (error: Error | null) => { + if (error) { + console.warn(`[MCPStore][${serverName}] Prompts list changed error:`, error); + return; + } + } + } + }; + } + + private handleToolsListChanged(serverName: string, tools: Tool[]): void { + const connection = this.connections.get(serverName); + if (!connection) { + return; + } + + for (const [toolName, ownerServer] of this.toolsIndex.entries()) { + if (ownerServer === serverName) this.toolsIndex.delete(toolName); + } + + connection.tools = tools; + + for (const tool of tools) { + if (this.toolsIndex.has(tool.name)) + console.warn( + `[MCPStore] Tool name conflict after list change: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${serverName}". Using tool from "${serverName}".` + ); + this.toolsIndex.set(tool.name, serverName); + } + this.updateState({ toolCount: this.toolsIndex.size }); + } + + acquireConnection(): void { + this.activeFlowCount++; + } + + /** + * Release a connection reference. + * By default, keeps connections alive for reuse (shutdownIfUnused=false). + * MCP spec encourages long-lived sessions to avoid reconnection overhead. + */ + async releaseConnection(shutdownIfUnused = false): Promise { + this.activeFlowCount = Math.max(0, this.activeFlowCount - 1); + if (shutdownIfUnused && this.activeFlowCount === 0) { + await this.shutdown(); + } + } + + getActiveFlowCount(): number { + return this.activeFlowCount; + } + + async shutdown(): Promise { + if (this.initPromise) { + await this.initPromise.catch(() => {}); + this.initPromise = null; + } + + if (this.connections.size === 0) { + return; + } + + await Promise.all( + Array.from(this.connections.values()).map((conn) => + MCPService.disconnect(conn).catch((error) => + console.warn(`[MCPStore] Error disconnecting ${conn.serverName}:`, error) + ) + ) + ); + + this.connections.clear(); + this.toolsIndex.clear(); + this.serverConfigs.clear(); + this.configSignature = null; + this.updateState({ + isInitializing: false, + error: null, + toolCount: 0, + connectedServers: [] + }); + } + + /** + * Immediately reconnect to a server by creating a fresh transport and session. + * Used when a session-expired error (HTTP 404) is detected during tool execution. + * Per MCP spec 2025-11-25: client MUST discard session ID and re-initialize. + * + * Unlike autoReconnect (which uses exponential backoff for connectivity issues), + * this performs a single immediate reconnection attempt since the server is known + * to be reachable (it responded with 404). + */ + private async reconnectServer(serverName: string): Promise { + const serverConfig = this.serverConfigs.get(serverName); + if (!serverConfig) { + throw new Error(`[MCPStore] No config found for ${serverName}, cannot reconnect`); + } + + // Disconnect stale connection (clears old transport + session ID) + const oldConnection = this.connections.get(serverName); + if (oldConnection) { + await MCPService.disconnect(oldConnection).catch(console.warn); + this.connections.delete(serverName); + } + + console.log(`[MCPStore][${serverName}] Session expired, reconnecting with fresh session...`); + + const listChangedHandlers = this.createListChangedHandlers(serverName); + const connection = await MCPService.connect( + serverName, + serverConfig, + DEFAULT_MCP_CONFIG.clientInfo, + DEFAULT_MCP_CONFIG.capabilities, + (phase) => { + if (phase === MCPConnectionPhase.DISCONNECTED) { + console.log(`[MCPStore][${serverName}] Connection lost, starting auto-reconnect`); + this.autoReconnect(serverName); + } + }, + listChangedHandlers + ); + + // Replace connection and rebuild tool index for this server + this.connections.set(serverName, connection); + for (const tool of connection.tools) { + this.toolsIndex.set(tool.name, serverName); + } + + console.log(`[MCPStore][${serverName}] Session recovered successfully`); + } + + /** + * Auto-reconnect to a server with exponential backoff. + * Continues indefinitely until successful. + * + * Race-condition safety: when the phase callback fires a DISCONNECTED event + * while we are still inside this function (e.g., the server drops right after + * a successful connect()), a naive inner `autoReconnect()` call would be + * swallowed by the `reconnectingServers` guard, leaving the server + * permanently disconnected once the outer call exits. We solve this by + * deferring the new reconnection via the `needsReconnect` flag: the flag is + * set inside the phase callback and honoured in the `finally` block after + * the guard entry has been removed. + */ + private async autoReconnect(serverName: string): Promise { + // Guard against concurrent reconnections + if (this.reconnectingServers.has(serverName)) { + console.log(`[MCPStore][${serverName}] Reconnection already in progress, skipping`); + + return; + } + + const serverConfig = this.serverConfigs.get(serverName); + if (!serverConfig) { + console.error(`[MCPStore] No config found for ${serverName}, cannot reconnect`); + + return; + } + + this.reconnectingServers.add(serverName); + let backoff = MCP_RECONNECT_INITIAL_DELAY; + // Flag set by the phase callback when a DISCONNECTED event fires while + // reconnectingServers still holds this server (see JSDoc above). + let needsReconnect = false; + + try { + while (true) { + await new Promise((resolve) => setTimeout(resolve, backoff)); + + console.log(`[MCPStore][${serverName}] Auto-reconnecting...`); + + try { + // Per-attempt timeout: reject if the server doesn't respond in time, + // then fall through to backoff logic as with any other failure. + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `Reconnect attempt timed out after ${MCP_RECONNECT_ATTEMPT_TIMEOUT_MS}ms` + ) + ), + MCP_RECONNECT_ATTEMPT_TIMEOUT_MS + ) + ); + + needsReconnect = false; + const listChangedHandlers = this.createListChangedHandlers(serverName); + const connectPromise = MCPService.connect( + serverName, + serverConfig, + DEFAULT_MCP_CONFIG.clientInfo, + DEFAULT_MCP_CONFIG.capabilities, + (phase) => { + if (phase === MCPConnectionPhase.DISCONNECTED) { + if (this.reconnectingServers.has(serverName)) { + // Reconnect loop is active; defer to after it exits. + needsReconnect = true; + } else { + console.log( + `[MCPStore][${serverName}] Connection lost, restarting auto-reconnect` + ); + this.autoReconnect(serverName); + } + } + }, + listChangedHandlers + ); + + const connection = await Promise.race([connectPromise, timeoutPromise]); + + // Replace old connection with new one + this.connections.set(serverName, connection); + + // Rebuild tool index for this server + for (const tool of connection.tools) { + this.toolsIndex.set(tool.name, serverName); + } + + console.log(`[MCPStore][${serverName}] Reconnected successfully`); + break; + } catch (error) { + console.warn(`[MCPStore][${serverName}] Reconnection failed:`, error); + backoff = Math.min(backoff * MCP_RECONNECT_BACKOFF_MULTIPLIER, MCP_RECONNECT_MAX_DELAY); + } + } + } finally { + this.reconnectingServers.delete(serverName); + // If the phase callback signalled a disconnect while this function held + // the guard, kick off a fresh reconnect now that the guard is released. + if (needsReconnect) { + console.log( + `[MCPStore][${serverName}] Deferred disconnect detected, restarting auto-reconnect` + ); + this.autoReconnect(serverName); + } + } + } + + getToolDefinitionsForLLM(): OpenAIToolDefinition[] { + const tools: OpenAIToolDefinition[] = []; + + for (const connection of this.connections.values()) { + for (const tool of connection.tools) { + const rawSchema = (tool.inputSchema as Record) ?? { + type: JsonSchemaType.OBJECT, + properties: {}, + required: [] + }; + + tools.push({ + type: ToolCallType.FUNCTION as const, + function: { + name: tool.name, + description: tool.description, + parameters: this.normalizeSchemaProperties(rawSchema) + } + }); + } + } + + return tools; + } + + private normalizeSchemaProperties(schema: Record): Record { + if (!schema || typeof schema !== 'object') { + return schema; + } + + const normalized = { ...schema }; + if (normalized.properties && typeof normalized.properties === 'object') { + const props = normalized.properties as Record>; + const normalizedProps: Record> = {}; + for (const [key, prop] of Object.entries(props)) { + if (!prop || typeof prop !== 'object') { + normalizedProps[key] = prop; + continue; + } + const normalizedProp = { ...prop }; + if (!normalizedProp.type && normalizedProp.default !== undefined) { + const defaultVal = normalizedProp.default; + if (typeof defaultVal === 'string') normalizedProp.type = 'string'; + else if (typeof defaultVal === 'number') + normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number'; + else if (typeof defaultVal === 'boolean') normalizedProp.type = 'boolean'; + else if (Array.isArray(defaultVal)) normalizedProp.type = 'array'; + else if (typeof defaultVal === 'object' && defaultVal !== null) + normalizedProp.type = 'object'; + } + if (normalizedProp.properties) + Object.assign( + normalizedProp, + this.normalizeSchemaProperties(normalizedProp as Record) + ); + if (normalizedProp.items && typeof normalizedProp.items === 'object') + normalizedProp.items = this.normalizeSchemaProperties( + normalizedProp.items as Record + ); + normalizedProps[key] = normalizedProp; + } + normalized.properties = normalizedProps; + } + + return normalized; + } + + getToolNames(): string[] { + return Array.from(this.toolsIndex.keys()); + } + + hasTool(toolName: string): boolean { + return this.toolsIndex.has(toolName); + } + + getToolServer(toolName: string): string | undefined { + return this.toolsIndex.get(toolName); + } + + hasPromptsSupport(): boolean { + for (const connection of this.connections.values()) { + if (connection.serverCapabilities?.prompts) { + return true; + } + } + + return false; + } + + /** + * Check if any enabled server with successful health check supports prompts. + * Uses health check state since servers may not have active connections until + * the user actually sends a message or uses prompts. + * @param perChatOverrides - Per-chat server overrides to filter by enabled servers. + * If provided (even empty array), only checks enabled servers. + * If undefined, checks all servers with successful health checks. + */ + hasPromptsCapability(perChatOverrides?: McpServerOverride[]): boolean { + // If perChatOverrides is provided (even empty array), filter by enabled servers + if (perChatOverrides !== undefined) { + const enabledServerIds = new Set( + perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId) + ); + + // No enabled servers = no capability + if (enabledServerIds.size === 0) { + return false; + } + + // Check health check states for enabled servers with prompts capability + for (const [serverId, state] of Object.entries(this._healthChecks)) { + if (!enabledServerIds.has(serverId)) continue; + if ( + state.status === HealthCheckStatus.SUCCESS && + state.capabilities?.server?.prompts !== undefined + ) { + return true; + } + } + + // Also check active connections as fallback + for (const [serverName, connection] of this.connections) { + if (!enabledServerIds.has(serverName)) continue; + if (connection.serverCapabilities?.prompts) { + return true; + } + } + + return false; + } + + // No overrides provided - check all servers (global mode) + for (const state of Object.values(this._healthChecks)) { + if ( + state.status === HealthCheckStatus.SUCCESS && + state.capabilities?.server?.prompts !== undefined + ) { + return true; + } + } + + for (const connection of this.connections.values()) { + if (connection.serverCapabilities?.prompts) { + return true; + } + } + + return false; + } + + async getAllPrompts(): Promise { + const results: MCPPromptInfo[] = []; + + for (const [serverName, connection] of this.connections) { + if (!connection.serverCapabilities?.prompts) continue; + + const prompts = await MCPService.listPrompts(connection); + + for (const prompt of prompts) { + results.push({ + name: prompt.name, + description: prompt.description, + title: prompt.title, + serverName, + arguments: prompt.arguments?.map((arg) => ({ + name: arg.name, + description: arg.description, + required: arg.required + })) + }); + } + } + + return results; + } + + async getPrompt( + serverName: string, + promptName: string, + args?: Record + ): Promise { + const connection = this.connections.get(serverName); + if (!connection) throw new Error(`Server "${serverName}" not found for prompt "${promptName}"`); + + return MCPService.getPrompt(connection, promptName, args); + } + + async executeTool(toolCall: MCPToolCall, signal?: AbortSignal): Promise { + const toolName = toolCall.function.name; + + const serverName = this.toolsIndex.get(toolName); + if (!serverName) throw new Error(`Unknown tool: ${toolName}`); + + const connection = this.connections.get(serverName); + if (!connection) throw new Error(`Server "${serverName}" is not connected`); + + const args = this.parseToolArguments(toolCall.function.arguments); + + try { + return await MCPService.callTool(connection, { name: toolName, arguments: args }, signal); + } catch (error) { + // Session expired (server restarted) - reconnect and retry once + if (MCPService.isSessionExpiredError(error)) { + await this.reconnectServer(serverName); + + const newConnection = this.connections.get(serverName); + if (!newConnection) throw new Error(`Failed to reconnect to "${serverName}"`); + + return MCPService.callTool(newConnection, { name: toolName, arguments: args }, signal); + } + + throw error; + } + } + + async executeToolByName( + toolName: string, + args: Record, + signal?: AbortSignal + ): Promise { + const serverName = this.toolsIndex.get(toolName); + if (!serverName) throw new Error(`Unknown tool: ${toolName}`); + const connection = this.connections.get(serverName); + if (!connection) throw new Error(`Server "${serverName}" is not connected`); + + try { + return await MCPService.callTool(connection, { name: toolName, arguments: args }, signal); + } catch (error) { + if (MCPService.isSessionExpiredError(error)) { + await this.reconnectServer(serverName); + + const newConnection = this.connections.get(serverName); + if (!newConnection) throw new Error(`Failed to reconnect to "${serverName}"`); + + return MCPService.callTool(newConnection, { name: toolName, arguments: args }, signal); + } + + throw error; + } + } + + private parseToolArguments(args: string | Record): Record { + if (typeof args === 'string') { + const trimmed = args.trim(); + if (trimmed === '') { + return {}; + } + + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) + throw new Error( + `Tool arguments must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}` + ); + + return parsed as Record; + } catch (error) { + throw new Error(`Failed to parse tool arguments as JSON: ${(error as Error).message}`); + } + } + + if (typeof args === 'object' && args !== null && !Array.isArray(args)) { + return args; + } + + throw new Error(`Invalid tool arguments type: ${typeof args}`); + } + + async getPromptCompletions( + serverName: string, + promptName: string, + argumentName: string, + argumentValue: string + ): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> { + const connection = this.connections.get(serverName); + if (!connection) { + console.warn(`[MCPStore] Server "${serverName}" is not connected`); + return null; + } + if (!connection.serverCapabilities?.completions) { + return null; + } + + return MCPService.complete( + connection, + { type: MCPRefType.PROMPT, name: promptName }, + { name: argumentName, value: argumentValue } + ); + } + + /** + * Get completions for a resource template argument. + * Uses the MCP Completion API with ref/resource. + */ + async getResourceCompletions( + serverName: string, + uriTemplate: string, + argumentName: string, + argumentValue: string + ): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> { + const connection = this.connections.get(serverName); + + if (!connection) { + console.warn(`[MCPStore] Server "${serverName}" is not connected`); + return null; + } + + if (!connection.serverCapabilities?.completions) { + return null; + } + + return MCPService.complete( + connection, + { type: MCPRefType.RESOURCE, uri: uriTemplate }, + { name: argumentName, value: argumentValue } + ); + } + + /** + * Read a resource by an arbitrary URI (e.g., one expanded from a template). + * Unlike readResource(), this does not require the URI to be in the resources list. + */ + async readResourceByUri(serverName: string, uri: string): Promise { + const connection = this.connections.get(serverName); + + if (!connection) { + console.error(`[MCPStore] No connection found for server: ${serverName}`); + + return null; + } + + try { + const result = await MCPService.readResource(connection, uri); + + return result.contents; + } catch (error) { + console.error(`[MCPStore] Failed to read resource ${uri}:`, error); + + return null; + } + } + + private parseHeaders(headersJson?: string): Record | undefined { + if (!headersJson?.trim()) { + return undefined; + } + + try { + const parsed = JSON.parse(headersJson); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) + return parsed as Record; + } catch { + console.warn('[MCPStore] Failed to parse custom headers JSON:', headersJson); + } + + return undefined; + } + + async runHealthChecksForServers( + servers: { + id: string; + enabled: boolean; + url: string; + requestTimeoutSeconds: number; + headers?: string; + }[], + skipIfChecked = true, + promoteToActive = false + ): Promise { + const serversToCheck = skipIfChecked + ? servers.filter((s) => !this.hasHealthCheck(s.id) && s.url.trim()) + : servers.filter((s) => s.url.trim()); + + if (serversToCheck.length === 0) { + return; + } + + const BATCH_SIZE = 5; + for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) { + const batch = serversToCheck.slice(i, i + BATCH_SIZE); + await Promise.allSettled(batch.map((server) => this.runHealthCheck(server, promoteToActive))); + } + } + + /** + * Check if a server already has an active connection that can be reused. + * Returns the existing connection if available. + */ + getExistingConnection(serverId: string): MCPConnection | undefined { + return this.connections.get(serverId); + } + + /** + * Run a health check for a server. + * If the server already has an active connection, reuses it instead of creating a new one. + * If promoteToActive is true and server is enabled, the connection will be kept + * and promoted to an active connection instead of being disconnected. + */ + async runHealthCheck(server: HealthCheckParams, promoteToActive = false): Promise { + // Check if we already have an active connection for this server + const existingConnection = this.connections.get(server.id); + if (existingConnection) { + // Reuse existing connection - just refresh tools list + try { + const tools = await MCPService.listTools(existingConnection); + const capabilities = this.#buildCapabilitiesInfo( + existingConnection.serverCapabilities, + existingConnection.clientCapabilities + ); + this.updateHealthCheck(server.id, { + status: HealthCheckStatus.SUCCESS, + tools: tools.map((tool) => ({ + name: tool.name, + description: tool.description, + title: tool.title + })), + serverInfo: existingConnection.serverInfo, + capabilities, + transportType: existingConnection.transportType, + protocolVersion: existingConnection.protocolVersion, + instructions: existingConnection.instructions, + connectionTimeMs: existingConnection.connectionTimeMs, + logs: [] + }); + return; + } catch (error) { + console.warn( + `[MCPStore] Failed to reuse connection for ${server.id}, creating new one:`, + error + ); + // Connection may be stale, remove it and create new one + this.connections.delete(server.id); + } + } + + const trimmedUrl = server.url.trim(); + const logs: MCPConnectionLog[] = []; + let currentPhase: MCPConnectionPhase = MCPConnectionPhase.IDLE; + + if (!trimmedUrl) { + this.updateHealthCheck(server.id, { + status: HealthCheckStatus.ERROR, + message: 'Please enter a server URL first.', + logs: [] + }); + return; + } + + this.updateHealthCheck(server.id, { + status: HealthCheckStatus.CONNECTING, + phase: MCPConnectionPhase.TRANSPORT_CREATING, + logs: [] + }); + + const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000); + const headers = this.parseHeaders(server.headers); + + try { + const serverConfig: MCPServerConfig = { + url: trimmedUrl, + transport: detectMcpTransportFromUrl(trimmedUrl), + handshakeTimeoutMs: DEFAULT_MCP_CONFIG.connectionTimeoutMs, + requestTimeoutMs: timeoutMs, + headers, + useProxy: server.useProxy + }; + + // Store config for reconnection + this.serverConfigs.set(server.id, serverConfig); + + const connection = await MCPService.connect( + server.id, + serverConfig, + DEFAULT_MCP_CONFIG.clientInfo, + DEFAULT_MCP_CONFIG.capabilities, + (phase, log) => { + currentPhase = phase; + logs.push(log); + this.updateHealthCheck(server.id, { + status: HealthCheckStatus.CONNECTING, + phase, + logs: [...logs] + }); + + // Handle WebSocket disconnection + if (phase === MCPConnectionPhase.DISCONNECTED && promoteToActive) { + console.log( + `[MCPStore][${server.id}] Connection lost during health check, starting auto-reconnect` + ); + this.autoReconnect(server.id); + } + } + ); + + const tools = connection.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + title: tool.title + })); + + const capabilities = this.#buildCapabilitiesInfo( + connection.serverCapabilities, + connection.clientCapabilities + ); + + this.updateHealthCheck(server.id, { + status: HealthCheckStatus.SUCCESS, + tools, + serverInfo: connection.serverInfo, + capabilities, + transportType: connection.transportType, + protocolVersion: connection.protocolVersion, + instructions: connection.instructions, + connectionTimeMs: connection.connectionTimeMs, + logs + }); + + // Promote to active connection or disconnect + if (promoteToActive && server.enabled) { + this.promoteHealthCheckToConnection(server.id, connection); + } else { + await MCPService.disconnect(connection); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error occurred'; + + if (logs.at(-1)?.phase !== MCPConnectionPhase.ERROR) { + logs.push({ + timestamp: new Date(), + phase: MCPConnectionPhase.ERROR, + message: `Connection failed: ${message}`, + level: MCPLogLevel.ERROR + }); + } + + this.updateHealthCheck(server.id, { + status: HealthCheckStatus.ERROR, + message, + phase: currentPhase, + logs + }); + } + } + + /** + * Promote a health check connection to an active connection. + * This avoids the need to reconnect when the server is needed for agentic flows. + */ + private promoteHealthCheckToConnection(serverId: string, connection: MCPConnection): void { + // Register tools from the connection + for (const tool of connection.tools) { + if (this.toolsIndex.has(tool.name)) { + console.warn( + `[MCPStore] Tool name conflict during promotion: "${tool.name}" exists in "${this.toolsIndex.get(tool.name)}" and "${serverId}". Using tool from "${serverId}".` + ); + } + this.toolsIndex.set(tool.name, serverId); + } + + // Add to active connections + this.connections.set(serverId, connection); + + // Update state + this.updateState({ + toolCount: this.toolsIndex.size, + connectedServers: Array.from(this.connections.keys()) + }); + } + + getServersStatus(): ServerStatus[] { + const statuses: ServerStatus[] = []; + + for (const [name, connection] of this.connections) { + statuses.push({ + name, + isConnected: true, + toolCount: connection.tools.length, + error: undefined + }); + } + + return statuses; + } + + /** + * Get aggregated server instructions from all connected servers. + * Returns an array of { serverName, serverTitle, instructions } objects. + */ + getServerInstructions(): Array<{ + serverName: string; + serverTitle?: string; + instructions: string; + }> { + const results: Array<{ serverName: string; serverTitle?: string; instructions: string }> = []; + + for (const [serverName, connection] of this.connections) { + if (connection.instructions) { + results.push({ + serverName, + serverTitle: connection.serverInfo?.title || connection.serverInfo?.name, + instructions: connection.instructions + }); + } + } + + return results; + } + + /** + * Get server instructions from health check results (for display before active connection). + * Useful for showing instructions in settings UI. + */ + getHealthCheckInstructions(): Array<{ + serverId: string; + serverTitle?: string; + instructions: string; + }> { + const results: Array<{ serverId: string; serverTitle?: string; instructions: string }> = []; + + for (const [serverId, state] of Object.entries(this._healthChecks)) { + if (state.status === HealthCheckStatus.SUCCESS && state.instructions) { + results.push({ + serverId, + serverTitle: state.serverInfo?.title || state.serverInfo?.name, + instructions: state.instructions + }); + } + } + + return results; + } + + /** + * Check if any connected server has instructions. + */ + hasServerInstructions(): boolean { + for (const connection of this.connections.values()) { + if (connection.instructions) { + return true; + } + } + + return false; + } + + /** + * + * + * Resources Operations + * + * + */ + + /** + * Check if any enabled server with successful health check supports resources. + * Uses health check state since servers may not have active connections until + * the user actually sends a message or uses prompts. + * @param perChatOverrides - Per-chat server overrides to filter by enabled servers. + * If provided (even empty array), only checks enabled servers. + * If undefined, checks all servers with successful health checks. + */ + hasResourcesCapability(perChatOverrides?: McpServerOverride[]): boolean { + // If perChatOverrides is provided (even empty array), filter by enabled servers + if (perChatOverrides !== undefined) { + const enabledServerIds = new Set( + perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId) + ); + // No enabled servers = no capability + if (enabledServerIds.size === 0) { + return false; + } + + // Check health check states for enabled servers with resources capability + for (const [serverId, state] of Object.entries(this._healthChecks)) { + if (!enabledServerIds.has(serverId)) continue; + if ( + state.status === HealthCheckStatus.SUCCESS && + state.capabilities?.server?.resources !== undefined + ) { + return true; + } + } + + // Also check active connections as fallback + for (const [serverName, connection] of this.connections) { + if (!enabledServerIds.has(serverName)) continue; + if (MCPService.supportsResources(connection)) { + return true; + } + } + + return false; + } + + // No overrides provided - check all servers (global mode) + for (const state of Object.values(this._healthChecks)) { + if ( + state.status === HealthCheckStatus.SUCCESS && + state.capabilities?.server?.resources !== undefined + ) { + return true; + } + } + + for (const connection of this.connections.values()) { + if (MCPService.supportsResources(connection)) { + return true; + } + } + + return false; + } + + /** + * Get list of servers that support resources. + * Checks active connections first, then health check state as fallback. + */ + getServersWithResources(): string[] { + const servers: string[] = []; + + // Check active connections + for (const [name, connection] of this.connections) { + if (MCPService.supportsResources(connection) && !servers.includes(name)) { + servers.push(name); + } + } + + // Also check health check states for servers not yet connected + for (const [serverId, state] of Object.entries(this._healthChecks)) { + if ( + !servers.includes(serverId) && + state.status === HealthCheckStatus.SUCCESS && + state.capabilities?.server?.resources !== undefined + ) { + servers.push(serverId); + } + } + + return servers; + } + + /** + * Fetch resources from all connected servers that support them. + * Updates mcpResourceStore with the results. + * @param forceRefresh - If true, bypass cache and fetch fresh data + */ + async fetchAllResources(forceRefresh: boolean = false): Promise { + const serversWithResources = this.getServersWithResources(); + if (serversWithResources.length === 0) { + return; + } + + // Check if we have cached resources and they're recent (unless force refresh) + if (!forceRefresh) { + const allServersCached = serversWithResources.every((serverName) => { + const serverRes = mcpResourceStore.getServerResources(serverName); + if (!serverRes || !serverRes.lastFetched) { + return false; + } + + // Cache is valid for 5 minutes + const age = Date.now() - serverRes.lastFetched.getTime(); + + return age < DEFAULT_CACHE_TTL_MS; + }); + + if (allServersCached) { + console.log('[MCPStore] Using cached resources'); + + return; + } + } + + mcpResourceStore.setLoading(true); + + try { + await Promise.all( + serversWithResources.map((serverName) => this.fetchServerResources(serverName)) + ); + } finally { + mcpResourceStore.setLoading(false); + } + } + + /** + * Fetch resources from a specific server. + * Updates mcpResourceStore with the results. + */ + async fetchServerResources(serverName: string): Promise { + const connection = this.connections.get(serverName); + if (!connection) { + console.warn(`[MCPStore] No connection found for server: ${serverName}`); + return; + } + + if (!MCPService.supportsResources(connection)) { + return; + } + + mcpResourceStore.setServerLoading(serverName, true); + + try { + const [resources, templates] = await Promise.all([ + MCPService.listAllResources(connection), + MCPService.listAllResourceTemplates(connection) + ]); + + mcpResourceStore.setServerResources(serverName, resources, templates); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + mcpResourceStore.setServerError(serverName, message); + console.error(`[MCPStore][${serverName}] Failed to fetch resources:`, error); + } + } + + /** + * Read resource content from a server. + * Caches the result in mcpResourceStore. + */ + async readResource(uri: string): Promise { + // Check cache first + const cached = mcpResourceStore.getCachedContent(uri); + if (cached) { + return cached.content; + } + + // Find which server has this resource + const serverName = mcpResourceStore.findServerForUri(uri); + if (!serverName) { + console.error(`[MCPStore] No server found for resource URI: ${uri}`); + + return null; + } + + const connection = this.connections.get(serverName); + if (!connection) { + console.error(`[MCPStore] No connection found for server: ${serverName}`); + + return null; + } + + try { + const result = await MCPService.readResource(connection, uri); + const resourceInfo = mcpResourceStore.findResourceByUri(uri); + + if (resourceInfo) { + mcpResourceStore.cacheResourceContent(resourceInfo, result.contents); + } + + return result.contents; + } catch (error) { + console.error(`[MCPStore] Failed to read resource ${uri}:`, error); + + return null; + } + } + + /** + * Subscribe to resource updates. + */ + async subscribeToResource(uri: string): Promise { + const serverName = mcpResourceStore.findServerForUri(uri); + if (!serverName) { + console.error(`[MCPStore] No server found for resource URI: ${uri}`); + + return false; + } + + const connection = this.connections.get(serverName); + if (!connection) { + console.error(`[MCPStore] No connection found for server: ${serverName}`); + + return false; + } + + if (!MCPService.supportsResourceSubscriptions(connection)) { + return false; + } + + try { + await MCPService.subscribeResource(connection, uri); + mcpResourceStore.addSubscription(uri, serverName); + + return true; + } catch (error) { + console.error(`[MCPStore] Failed to subscribe to resource ${uri}:`, error); + + return false; + } + } + + /** + * Unsubscribe from resource updates. + */ + async unsubscribeFromResource(uri: string): Promise { + const serverName = mcpResourceStore.findServerForUri(uri); + if (!serverName) { + console.error(`[MCPStore] No server found for resource URI: ${uri}`); + + return false; + } + + const connection = this.connections.get(serverName); + if (!connection) { + console.error(`[MCPStore] No connection found for server: ${serverName}`); + + return false; + } + + try { + await MCPService.unsubscribeResource(connection, uri); + mcpResourceStore.removeSubscription(uri); + + return true; + } catch (error) { + console.error(`[MCPStore] Failed to unsubscribe from resource ${uri}:`, error); + + return false; + } + } + + /** + * Add a resource as attachment to chat context. + * Automatically fetches content if not cached. + */ + async attachResource(uri: string): Promise { + const resourceInfo = mcpResourceStore.findResourceByUri(uri); + if (!resourceInfo) { + console.error(`[MCPStore] Resource not found: ${uri}`); + + return null; + } + + // Check if already attached + if (mcpResourceStore.isAttached(uri)) { + return null; + } + + // Add attachment (initially loading) + const attachment = mcpResourceStore.addAttachment(resourceInfo); + + // Fetch content + try { + const content = await this.readResource(uri); + + if (content) { + mcpResourceStore.updateAttachmentContent(attachment.id, content); + } else { + mcpResourceStore.updateAttachmentError(attachment.id, 'Failed to read resource'); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + mcpResourceStore.updateAttachmentError(attachment.id, message); + } + + return mcpResourceStore.getAttachment(attachment.id) ?? null; + } + + /** + * Remove a resource attachment from chat context. + */ + removeResourceAttachment(attachmentId: string): void { + mcpResourceStore.removeAttachment(attachmentId); + } + + /** + * Clear all resource attachments. + */ + clearResourceAttachments(): void { + mcpResourceStore.clearAttachments(); + } + + /** + * Get formatted resource context for chat. + */ + getResourceContextForChat(): string { + return mcpResourceStore.formatAttachmentsForContext(); + } + + /** + * Convert current resource attachments to DatabaseMessageExtra[] and clear them. + * Called during message send to persist resources with the user message. + */ + consumeResourceAttachmentsAsExtras(): DatabaseMessageExtraMcpResource[] { + const extras = mcpResourceStore.toMessageExtras(); + if (extras.length > 0) { + mcpResourceStore.clearAttachments(); + } + return extras; + } +} + +export const mcpStore = new MCPStore(); + +export const mcpIsInitializing = () => mcpStore.isInitializing; +export const mcpIsInitialized = () => mcpStore.isInitialized; +export const mcpError = () => mcpStore.error; +export const mcpIsEnabled = () => mcpStore.isEnabled; +export const mcpIsProxyAvailable = () => mcpStore.isProxyAvailable; +export const mcpAvailableTools = () => mcpStore.availableTools; +export const mcpConnectedServerCount = () => mcpStore.connectedServerCount; +export const mcpConnectedServerNames = () => mcpStore.connectedServerNames; +export const mcpToolCount = () => mcpStore.toolCount; +export const mcpServerInstructions = () => mcpStore.getServerInstructions(); +export const mcpHasServerInstructions = () => mcpStore.hasServerInstructions(); + +// Resources exports +export const mcpHasResourcesCapability = () => mcpStore.hasResourcesCapability(); +export const mcpServersWithResources = () => mcpStore.getServersWithResources(); +export const mcpResourceContext = () => mcpStore.getResourceContextForChat(); diff --git a/tools/ui/src/lib/stores/models.svelte.ts b/tools/ui/src/lib/stores/models.svelte.ts new file mode 100644 index 000000000..63943ff7a --- /dev/null +++ b/tools/ui/src/lib/stores/models.svelte.ts @@ -0,0 +1,832 @@ +import { SvelteMap, SvelteSet } from 'svelte/reactivity'; +import { toast } from 'svelte-sonner'; +import { ServerModelStatus, ModelModality } from '$lib/enums'; +import { ModelsService } from '$lib/services/models.service'; +import { PropsService } from '$lib/services/props.service'; +import { serverStore } from '$lib/stores/server.svelte'; +import { TTLCache } from '$lib/utils'; +import { + MODEL_PROPS_CACHE_TTL_MS, + MODEL_PROPS_CACHE_MAX_ENTRIES, + FAVORITE_MODELS_LOCALSTORAGE_KEY +} from '$lib/constants'; + +import { conversationsStore } from '$lib/stores/conversations.svelte'; + +/** + * modelsStore - Reactive store for model management in both MODEL and ROUTER modes + * + * This store manages: + * - Available models list + * - Selected model for new conversations + * - Loaded models tracking (ROUTER mode) + * - Model usage tracking per conversation + * - Automatic unloading of unused models + * + * **Architecture & Relationships:** + * - **ModelsService**: Stateless service for model API communication + * - **PropsService**: Stateless service for props/modalities fetching + * - **modelsStore** (this class): Reactive store for model state + * - **conversationsStore**: Tracks which conversations use which models + * + * **API Inconsistency Workaround:** + * In MODEL mode, `/props` returns modalities for the single model. + * In ROUTER mode, `/props` has no modalities - must use `/props?model=` per model. + * This store normalizes this behavior so consumers don't need to know the server mode. + * + * **Key Features:** + * - **MODEL mode**: Single model, always loaded + * - **ROUTER mode**: Multi-model with load/unload capability + * - **Auto-unload**: Automatically unloads models not used by any conversation + * - **Lazy loading**: ensureModelLoaded() loads models on demand + */ +class ModelsStore { + /** + * + * + * State + * + * + */ + + models = $state([]); + routerModels = $state([]); + loading = $state(false); + updating = $state(false); + error = $state(null); + selectedModelId = $state(null); + selectedModelName = $state(null); + + // dedup concurrent fetch() callers, all awaiters share the same inflight promise + // without this, ?model= URL handler raced an in-progress fetch and saw an empty list + private inflightFetch: Promise | null = null; + + private modelUsage = $state>>(new Map()); + private modelLoadingStates = new SvelteMap(); + + favoriteModelIds = $state>(this.loadFavoritesFromStorage()); + + /** + * Model-specific props cache with TTL + * Key: modelId, Value: props data including modalities + * TTL: 10 minutes - props don't change frequently + */ + private modelPropsCache = new TTLCache({ + ttlMs: MODEL_PROPS_CACHE_TTL_MS, + maxEntries: MODEL_PROPS_CACHE_MAX_ENTRIES + }); + private modelPropsFetching = $state>(new Set()); + + /** + * Version counter for props cache - used to trigger reactivity when props are updated + */ + propsCacheVersion = $state(0); + + /** + * + * + * Computed Getters + * + * + */ + + get selectedModel(): ModelOption | null { + if (!this.selectedModelId) return null; + return this.models.find((model) => model.id === this.selectedModelId) ?? null; + } + + get loadedModelIds(): string[] { + return this.routerModels + .filter( + (m) => + m.status.value === ServerModelStatus.LOADED || + m.status.value === ServerModelStatus.SLEEPING + ) + .map((m) => m.id); + } + + get loadingModelIds(): string[] { + return Array.from(this.modelLoadingStates.entries()) + .filter(([, loading]) => loading) + .map(([id]) => id); + } + + /** + * Get model name in MODEL mode (single model). + * Extracts from model_path or model_alias from server props. + * In ROUTER mode, returns null (model is per-conversation). + */ + get singleModelName(): string | null { + if (serverStore.isRouterMode) return null; + + const props = serverStore.props; + if (props?.model_alias) return props.model_alias; + if (!props?.model_path) return null; + + return props.model_path.split(/(\\|\/)/).pop() || null; + } + + /** + * + * + * Modalities + * + * + */ + + /** + * Get modalities for a specific model + * Returns cached modalities from model props + */ + getModelModalities(modelId: string): ModelModalities | null { + const model = this.models.find((m) => m.model === modelId || m.id === modelId); + if (model?.modalities) { + return model.modalities; + } + + const props = this.modelPropsCache.get(modelId); + if (props?.modalities) { + return { + vision: props.modalities.vision ?? false, + audio: props.modalities.audio ?? false + }; + } + + return null; + } + + /** + * Check if a model supports vision modality + */ + modelSupportsVision(modelId: string): boolean { + return this.getModelModalities(modelId)?.vision ?? false; + } + + /** + * Check if a model supports audio modality + */ + modelSupportsAudio(modelId: string): boolean { + return this.getModelModalities(modelId)?.audio ?? false; + } + + /** + * Get model modalities as an array of ModelModality enum values + */ + getModelModalitiesArray(modelId: string): ModelModality[] { + const modalities = this.getModelModalities(modelId); + if (!modalities) return []; + + const result: ModelModality[] = []; + + if (modalities.vision) result.push(ModelModality.VISION); + if (modalities.audio) result.push(ModelModality.AUDIO); + + return result; + } + + /** + * Get props for a specific model (from cache) + */ + getModelProps(modelId: string): ApiLlamaCppServerProps | null { + return this.modelPropsCache.get(modelId); + } + + /** + * Get context size (n_ctx) for a specific model from cached props + */ + getModelContextSize(modelId: string): number | null { + const props = this.getModelProps(modelId); + const nCtx = props?.default_generation_settings?.n_ctx; + + return typeof nCtx === 'number' ? nCtx : null; + } + + /** + * Get context size for the currently selected model or null if no model is selected + */ + get selectedModelContextSize(): number | null { + if (!this.selectedModelName) return null; + return this.getModelContextSize(this.selectedModelName); + } + + /** + * Check if props are being fetched for a model + */ + isModelPropsFetching(modelId: string): boolean { + return this.modelPropsFetching.has(modelId); + } + + /** + * + * + * Status Queries + * + * + */ + + isModelLoaded(modelId: string): boolean { + const model = this.routerModels.find((m) => m.id === modelId); + return ( + model?.status.value === ServerModelStatus.LOADED || + model?.status.value === ServerModelStatus.SLEEPING || + false + ); + } + + isModelOperationInProgress(modelId: string): boolean { + return this.modelLoadingStates.get(modelId) ?? false; + } + + getModelStatus(modelId: string): ServerModelStatus | null { + const model = this.routerModels.find((m) => m.id === modelId); + return model?.status.value ?? null; + } + + getModelUsage(modelId: string): SvelteSet { + return this.modelUsage.get(modelId) ?? new SvelteSet(); + } + + isModelInUse(modelId: string): boolean { + const usage = this.modelUsage.get(modelId); + return usage !== undefined && usage.size > 0; + } + + /** + * + * + * Data Fetching + * + * + */ + + /** + * Fetch list of models from server and detect server role + * Also fetches modalities for MODEL mode (single model) + */ + async fetch(force = false): Promise { + if (this.inflightFetch) return this.inflightFetch; + if (this.models.length > 0 && !force) return; + + this.inflightFetch = this.runFetch(); + try { + await this.inflightFetch; + } finally { + this.inflightFetch = null; + } + } + + private async runFetch(): Promise { + this.loading = true; + this.error = null; + + try { + if (!serverStore.props) { + await serverStore.fetch(); + } + + const response = await ModelsService.list(); + + const models: ModelOption[] = response.data.map((item: ApiModelDataEntry, index: number) => { + const details = response.models?.[index]; + const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : []; + const displayNameSource = + details?.name && details.name.trim().length > 0 ? details.name : item.id; + const displayName = this.toDisplayName(displayNameSource); + const modelId = details?.model || item.id; + + return { + id: item.id, + name: displayName, + model: modelId, + description: details?.description, + capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)), + details: details?.details, + meta: item.meta ?? null, + parsedId: ModelsService.parseModelId(modelId), + aliases: item.aliases ?? [], + tags: item.tags ?? [] + } satisfies ModelOption; + }); + + this.models = models; + + // WORKAROUND: In MODEL mode, /props returns modalities for the single model, + // but /v1/models doesn't include modalities. We bridge this gap here. + const serverProps = serverStore.props; + if (serverStore.isModelMode && this.models.length > 0 && serverProps?.modalities) { + const modalities: ModelModalities = { + vision: serverProps.modalities.vision ?? false, + audio: serverProps.modalities.audio ?? false + }; + this.modelPropsCache.set(this.models[0].model, serverProps); + this.models = this.models.map((model, index) => + index === 0 ? { ...model, modalities } : model + ); + } + } catch (error) { + this.models = []; + this.error = error instanceof Error ? error.message : 'Failed to load models'; + throw error; + } finally { + this.loading = false; + } + } + + /** + * Fetch router models with full metadata (ROUTER mode only) + * This fetches the /models endpoint which returns status info for each model + */ + async fetchRouterModels(): Promise { + try { + const response = await ModelsService.listRouter(); + this.routerModels = response.data; + await this.fetchModalitiesForLoadedModels(); + + const o = this.models.filter((option) => this.getModelProps(option.model)?.ui !== false); + + if (o.length === 1 && this.isModelLoaded(o[0].model)) { + this.selectModelById(o[0].id); + } + } catch (error) { + console.warn('Failed to fetch router models:', error); + this.routerModels = []; + } + } + + /** + * Fetch props for a specific model from /props endpoint + * Uses caching to avoid redundant requests + * + * In ROUTER mode, this will only fetch props if the model is loaded, + * since unloaded models return 400 from /props endpoint. + * + * @param modelId - Model identifier to fetch props for + * @returns Props data or null if fetch failed or model not loaded + */ + async fetchModelProps(modelId: string): Promise { + const cached = this.modelPropsCache.get(modelId); + if (cached) return cached; + + if (serverStore.isRouterMode && !this.isModelLoaded(modelId)) { + return null; + } + + if (this.modelPropsFetching.has(modelId)) return null; + + this.modelPropsFetching.add(modelId); + + try { + const props = await PropsService.fetchForModel(modelId); + this.modelPropsCache.set(modelId, props); + return props; + } catch (error) { + console.warn(`Failed to fetch props for model ${modelId}:`, error); + return null; + } finally { + this.modelPropsFetching.delete(modelId); + } + } + + /** + * Fetch modalities for all loaded models from /props endpoint + * This updates the modalities field in models array + */ + async fetchModalitiesForLoadedModels(): Promise { + const loadedModelIds = this.loadedModelIds; + if (loadedModelIds.length === 0) return; + + const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId)); + + try { + const results = await Promise.all(propsPromises); + + // Update models with modalities + this.models = this.models.map((model) => { + const modelIndex = loadedModelIds.indexOf(model.model); + if (modelIndex === -1) return model; + + const props = results[modelIndex]; + if (!props?.modalities) return model; + + const modalities: ModelModalities = { + vision: props.modalities.vision ?? false, + audio: props.modalities.audio ?? false + }; + + return { ...model, modalities }; + }); + + this.propsCacheVersion++; + } catch (error) { + console.warn('Failed to fetch modalities for loaded models:', error); + } + } + + /** + * Gets the model name from the last assistant message in the active conversation. + * Iterates backward through messages to find the most recent message with a model. + * Used by both the chat page and settings page to maintain model consistency. + * @returns The model name or null if not found + */ + getModelFromLastAssistantResponse(): string | null { + const messages = conversationsStore.activeMessages; + if (!messages || messages.length === 0) return null; + + // Iterate backward to find the last message with a model + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].model) { + return messages[i].model; + } + } + + return null; + } + + /** + * Auto-selects the model from the last assistant response if available and loaded. + * Returns true if a model was selected, false otherwise. + * This is used by the chat page to maintain model consistency across page navigation. + */ + async selectModelFromLastAssistantResponse(): Promise { + const lastModel = this.getModelFromLastAssistantResponse(); + if (!lastModel) return false; + + // Skip if already selected + if (this.selectedModelName === lastModel) return false; + + const matchingModel = this.models.find((option) => option.model === lastModel); + if (!matchingModel) return false; + + if (!this.isModelLoaded(lastModel)) { + console.log('[modelsStore] last assistant model not loaded:', lastModel); + return false; + } + + try { + await this.selectModelById(matchingModel.id); + console.log(`[modelsStore] Automatically selected model: ${lastModel} from last message`); + return true; + } catch (error) { + console.warn('[modelsStore] Failed to automatically select model from last message:', error); + return false; + } + } + + /** + * Auto-selects the first available model if none is selected, and fetches its props. + * Prioritizes: + * 1. Model from active conversation's last assistant response (if loaded) + * 2. Model from active conversation's last assistant response (if not loaded) + * 3. First loaded model (not from active conversation) + * 4. First available model + * This is used to ensure default values are populated in settings pages. + */ + async ensureFirstModelSelected(): Promise { + if (this.selectedModelName) return; + + // Filter models that are visible in the UI + const availableModels = this.models.filter( + (option) => this.getModelProps(option.model)?.ui !== false + ); + + if (availableModels.length === 0) return; + + // Try to select model from last assistant response first + const lastModel = this.getModelFromLastAssistantResponse(); + if (lastModel) { + const lastModelOption = availableModels.find((m) => m.model === lastModel); + if (lastModelOption) { + await this.selectModelById(lastModelOption.id); + if (this.isModelLoaded(lastModel)) { + await this.fetchModelProps(lastModel); + } + return; + } + } + + // Try to find a loaded model first + const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model)); + if (loadedModel) { + await this.selectModelById(loadedModel.id); + await this.fetchModelProps(loadedModel.model); + return; + } + + // Fall back to the first available model + const firstModel = availableModels[0]; + await this.selectModelById(firstModel.id); + // Don't fetch props for unloaded models (will fail in ROUTER mode) + } + + /** + * Update modalities for a specific model + * Called when a model is loaded or when we need fresh modality data + */ + async updateModelModalities(modelId: string): Promise { + try { + const props = await this.fetchModelProps(modelId); + if (!props?.modalities) return; + + const modalities: ModelModalities = { + vision: props.modalities.vision ?? false, + audio: props.modalities.audio ?? false + }; + + this.models = this.models.map((model) => + model.model === modelId ? { ...model, modalities } : model + ); + + this.propsCacheVersion++; + } catch (error) { + console.warn(`Failed to update modalities for model ${modelId}:`, error); + } + } + + /** + * + * + * Model Selection + * + * + */ + + /** + * Select a model for new conversations + */ + async selectModelById(modelId: string): Promise { + if (!modelId || this.updating) return; + if (this.selectedModelId === modelId) return; + + const option = this.models.find((model) => model.id === modelId); + if (!option) throw new Error('Selected model is not available'); + + this.updating = true; + this.error = null; + + try { + this.selectedModelId = option.id; + this.selectedModelName = option.model; + } finally { + this.updating = false; + } + } + + /** + * Select a model by its model name (used for syncing with conversation model) + * @param modelName - Model name to select (e.g., "ggml-org/GLM-4.7-Flash-GGUF") + */ + selectModelByName(modelName: string): void { + const option = this.models.find((model) => model.model === modelName); + if (option) { + this.selectedModelId = option.id; + this.selectedModelName = option.model; + } + } + + clearSelection(): void { + this.selectedModelId = null; + this.selectedModelName = null; + } + + findModelByName(modelName: string): ModelOption | null { + return this.models.find((model) => model.model === modelName) ?? null; + } + + findModelById(modelId: string): ModelOption | null { + return this.models.find((model) => model.id === modelId) ?? null; + } + + hasModel(modelName: string): boolean { + return this.models.some((model) => model.model === modelName); + } + + /** + * + * + * Loading/Unloading Models + * + * + */ + + /** + * WORKAROUND: Polling for model status after load/unload operations. + * + * Currently, the `/models/load` and `/models/unload` endpoints return success + * before the operation actually completes on the server. This means an immediate + * request to `/models` returns stale status (e.g., "loading" after load request, + * "loaded" after unload request). + * + * TODO: Remove this polling once llama-server properly waits for the operation + * to complete before returning success from `/load` and `/unload` endpoints. + * At that point, a single `fetchRouterModels()` call after the operation will + * be sufficient to get the correct status. + */ + + /** Polling interval in ms for checking model status */ + private static readonly STATUS_POLL_INTERVAL = 500; + + /** + * Poll for expected model status after load/unload operation. + * Keeps polling indefinitely until the model reaches the expected status or fails. + * + * @param modelId - Model identifier to check + * @param expectedStatus - Expected status to wait for + * @throws Error if model reaches FAILED status + */ + private async pollForModelStatus( + modelId: string, + expectedStatus: ServerModelStatus + ): Promise { + let attempt = 0; + while (true) { + await this.fetchRouterModels(); + + const currentStatus = this.getModelStatus(modelId); + if (currentStatus === expectedStatus) { + return; + } + + if (currentStatus === ServerModelStatus.FAILED) { + throw new Error( + `Model failed to ${expectedStatus === ServerModelStatus.LOADED ? 'load' : 'unload'}` + ); + } + + if ( + expectedStatus === ServerModelStatus.LOADED && + currentStatus === ServerModelStatus.UNLOADED && + attempt > 2 + ) { + throw new Error('Model was unloaded unexpectedly during loading'); + } + + attempt++; + await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL)); + } + } + + /** + * Load a model (ROUTER mode) + * @param modelId - Model identifier to load + */ + async loadModel(modelId: string): Promise { + if (this.isModelLoaded(modelId)) { + return; + } + + if (this.modelLoadingStates.get(modelId)) return; + + this.modelLoadingStates.set(modelId, true); + this.error = null; + + try { + await ModelsService.load(modelId); + await this.pollForModelStatus(modelId, ServerModelStatus.LOADED); + + await this.updateModelModalities(modelId); + toast.success(`Model loaded: ${this.toDisplayName(modelId)}`); + } catch (error) { + this.error = error instanceof Error ? error.message : 'Failed to load model'; + toast.error(`Failed to load model: ${this.toDisplayName(modelId)}`); + throw error; + } finally { + this.modelLoadingStates.set(modelId, false); + } + } + + /** + * Unload a model (ROUTER mode) + * @param modelId - Model identifier to unload + */ + async unloadModel(modelId: string): Promise { + if (!this.isModelLoaded(modelId)) { + return; + } + + if (this.modelLoadingStates.get(modelId)) return; + + this.modelLoadingStates.set(modelId, true); + this.error = null; + + try { + await ModelsService.unload(modelId); + + await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED); + toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`); + } catch (error) { + this.error = error instanceof Error ? error.message : 'Failed to unload model'; + toast.error(`Failed to unload model: ${this.toDisplayName(modelId)}`); + throw error; + } finally { + this.modelLoadingStates.set(modelId, false); + } + } + + /** + * Ensure a model is loaded before use + * @param modelId - Model identifier to ensure is loaded + */ + async ensureModelLoaded(modelId: string): Promise { + if (this.isModelLoaded(modelId)) { + return; + } + + await this.loadModel(modelId); + } + + /** + * + * + * Favorites + * + * + */ + + isFavorite(modelId: string): boolean { + return this.favoriteModelIds.has(modelId); + } + + toggleFavorite(modelId: string): void { + const next = new SvelteSet(this.favoriteModelIds); + + if (next.has(modelId)) { + next.delete(modelId); + } else { + next.add(modelId); + } + + this.favoriteModelIds = next; + + try { + localStorage.setItem(FAVORITE_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next])); + } catch { + toast.error('Failed to save favorite models to local storage'); + } + } + + private loadFavoritesFromStorage(): Set { + try { + const raw = localStorage.getItem(FAVORITE_MODELS_LOCALSTORAGE_KEY); + + return raw ? new Set(JSON.parse(raw) as string[]) : new Set(); + } catch { + toast.error('Failed to load favorite models from local storage'); + + return new Set(); + } + } + + /** + * + * + * Utilities + * + * + */ + + private toDisplayName(id: string): string { + const segments = id.split(/\\|\//); + const candidate = segments.pop(); + + return candidate && candidate.trim().length > 0 ? candidate : id; + } + + clear(): void { + this.models = []; + this.routerModels = []; + this.loading = false; + this.updating = false; + this.error = null; + this.selectedModelId = null; + this.selectedModelName = null; + this.modelUsage.clear(); + this.modelLoadingStates.clear(); + this.modelPropsCache.clear(); + this.modelPropsFetching.clear(); + } + + /** + * Prune expired entries from caches. + * Call periodically for proactive memory cleanup. + */ + pruneExpiredCache(): number { + return this.modelPropsCache.prune(); + } +} + +export const modelsStore = new ModelsStore(); + +export const modelOptions = () => modelsStore.models; +export const routerModels = () => modelsStore.routerModels; +export const modelsLoading = () => modelsStore.loading; +export const modelsUpdating = () => modelsStore.updating; +export const modelsError = () => modelsStore.error; +export const selectedModelId = () => modelsStore.selectedModelId; +export const selectedModelName = () => modelsStore.selectedModelName; +export const selectedModelOption = () => modelsStore.selectedModel; +export const loadedModelIds = () => modelsStore.loadedModelIds; +export const loadingModelIds = () => modelsStore.loadingModelIds; +export const propsCacheVersion = () => modelsStore.propsCacheVersion; +export const singleModelName = () => modelsStore.singleModelName; +export const selectedModelContextSize = () => modelsStore.selectedModelContextSize; +export const favoriteModelIds = () => modelsStore.favoriteModelIds; diff --git a/tools/ui/src/lib/stores/permissions.svelte.ts b/tools/ui/src/lib/stores/permissions.svelte.ts new file mode 100644 index 000000000..c50fbe02d --- /dev/null +++ b/tools/ui/src/lib/stores/permissions.svelte.ts @@ -0,0 +1,59 @@ +import { ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY } from '$lib/constants'; + +import { SvelteSet } from 'svelte/reactivity'; + +class PermissionsStore { + private _tools = $state(new SvelteSet()); + + constructor() { + try { + const stored = localStorage.getItem(ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY); + if (stored) { + for (const name of JSON.parse(stored) as string[]) { + if (typeof name === 'string') this._tools.add(name); + } + } + } catch (err) { + console.error( + `Failed to load permissions from localStorage ("${ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY}"):`, + err + ); + } + } + + get tools(): ReadonlySet { + return this._tools; + } + + hasTool(key: string): boolean { + return this._tools.has(key); + } + + allowTool(key: string): void { + this._tools.add(key); + this._persist(); + } + + allowTools(keys: string[]): void { + for (const key of keys) this._tools.add(key); + this._persist(); + } + + revokeTool(key: string): void { + this._tools.delete(key); + this._persist(); + } + + private _persist(): void { + try { + localStorage.setItem(ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY, JSON.stringify([...this._tools])); + } catch (err) { + console.error( + `Failed to persist to localStorage ("${ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY}"):`, + err + ); + } + } +} + +export const permissionsStore = new PermissionsStore(); diff --git a/tools/ui/src/lib/stores/persisted.svelte.ts b/tools/ui/src/lib/stores/persisted.svelte.ts new file mode 100644 index 000000000..1e07f80ed --- /dev/null +++ b/tools/ui/src/lib/stores/persisted.svelte.ts @@ -0,0 +1,50 @@ +import { browser } from '$app/environment'; + +type PersistedValue = { + get value(): T; + set value(newValue: T); +}; + +export function persisted(key: string, initialValue: T): PersistedValue { + let value = initialValue; + + if (browser) { + try { + const stored = localStorage.getItem(key); + + if (stored !== null) { + value = JSON.parse(stored) as T; + } + } catch (error) { + console.warn(`Failed to load ${key}:`, error); + } + } + + const persist = (next: T) => { + if (!browser) { + return; + } + + try { + if (next === null || next === undefined) { + localStorage.removeItem(key); + return; + } + + localStorage.setItem(key, JSON.stringify(next)); + } catch (error) { + console.warn(`Failed to persist ${key}:`, error); + } + }; + + return { + get value() { + return value; + }, + + set value(newValue: T) { + value = newValue; + persist(newValue); + } + }; +} diff --git a/tools/ui/src/lib/stores/server.svelte.ts b/tools/ui/src/lib/stores/server.svelte.ts new file mode 100644 index 000000000..dfcb9b2bb --- /dev/null +++ b/tools/ui/src/lib/stores/server.svelte.ts @@ -0,0 +1,158 @@ +import { PropsService } from '$lib/services/props.service'; +import { ServerRole } from '$lib/enums'; + +/** + * serverStore - Server connection state, configuration, and role detection + * + * This store manages the server connection state and properties fetched from `/props`. + * It provides reactive state for server configuration and role detection. + * + * **Architecture & Relationships:** + * - **PropsService**: Stateless service for fetching `/props` data + * - **serverStore** (this class): Reactive store for server state + * - **modelsStore**: Independent store for model management (uses PropsService directly) + * + * **Key Features:** + * - **Server State**: Connection status, loading, error handling + * - **Role Detection**: MODEL (single model) vs ROUTER (multi-model) + * - **Default Params**: Server-wide generation defaults + */ +class ServerStore { + /** + * + * + * State + * + * + */ + + props = $state(null); + loading = $state(false); + error = $state(null); + role = $state(null); + private fetchPromise: Promise | null = null; + + /** + * + * + * Getters + * + * + */ + + get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null { + return this.props?.default_generation_settings?.params || null; + } + + get contextSize(): number | null { + const nCtx = this.props?.default_generation_settings?.n_ctx; + + return typeof nCtx === 'number' ? nCtx : null; + } + + get uiSettings(): Record | undefined { + return this.props?.ui_settings ?? this.props?.webui_settings; + } + + get isRouterMode(): boolean { + return this.role === ServerRole.ROUTER; + } + + get isModelMode(): boolean { + return this.role === ServerRole.MODEL; + } + + /** + * + * + * Data Handling + * + * + */ + + async fetch(): Promise { + if (this.fetchPromise) return this.fetchPromise; + + this.loading = true; + this.error = null; + + const fetchPromise = (async () => { + try { + const props = await PropsService.fetch(); + this.props = props; + this.error = null; + this.detectRole(props); + } catch (error) { + this.error = this.getErrorMessage(error); + console.error('Error fetching server properties:', error); + } finally { + this.loading = false; + this.fetchPromise = null; + } + })(); + + this.fetchPromise = fetchPromise; + await fetchPromise; + } + + private getErrorMessage(error: unknown): string { + if (error instanceof Error) { + const message = error.message || ''; + + if (error.name === 'TypeError' && message.includes('fetch')) { + return 'Server is not running or unreachable'; + } else if (message.includes('ECONNREFUSED')) { + return 'Connection refused - server may be offline'; + } else if (message.includes('ENOTFOUND')) { + return 'Server not found - check server address'; + } else if (message.includes('ETIMEDOUT')) { + return 'Request timed out'; + } else if (message.includes('503')) { + return 'Server temporarily unavailable'; + } else if (message.includes('500')) { + return 'Server error - check server logs'; + } else if (message.includes('404')) { + return 'Server endpoint not found'; + } else if (message.includes('403') || message.includes('401')) { + return 'Access denied'; + } + } + + return 'Failed to connect to server'; + } + + clear(): void { + this.props = null; + this.error = null; + this.loading = false; + this.role = null; + this.fetchPromise = null; + } + + /** + * + * + * Utilities + * + * + */ + + private detectRole(props: ApiLlamaCppServerProps): void { + const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL; + if (this.role !== newRole) { + this.role = newRole; + console.info(`Server running in ${newRole === ServerRole.ROUTER ? 'ROUTER' : 'MODEL'} mode`); + } + } +} + +export const serverStore = new ServerStore(); + +export const serverProps = () => serverStore.props; +export const serverLoading = () => serverStore.loading; +export const serverError = () => serverStore.error; +export const serverRole = () => serverStore.role; +export const defaultParams = () => serverStore.defaultParams; +export const contextSize = () => serverStore.contextSize; +export const isRouterMode = () => serverStore.isRouterMode; +export const isModelMode = () => serverStore.isModelMode; diff --git a/tools/ui/src/lib/stores/settings-referrer.svelte.ts b/tools/ui/src/lib/stores/settings-referrer.svelte.ts new file mode 100644 index 000000000..297a0d6a4 --- /dev/null +++ b/tools/ui/src/lib/stores/settings-referrer.svelte.ts @@ -0,0 +1,12 @@ +import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants'; + +let _url = $state(SETTINGS_FALLBACK_EXIT_ROUTE); + +export const settingsReferrer = { + get url() { + return _url; + }, + set url(value: string) { + _url = value; + } +}; diff --git a/tools/ui/src/lib/stores/settings.svelte.ts b/tools/ui/src/lib/stores/settings.svelte.ts new file mode 100644 index 000000000..58eea1ee6 --- /dev/null +++ b/tools/ui/src/lib/stores/settings.svelte.ts @@ -0,0 +1,547 @@ +/** + * settingsStore - Application configuration and theme management + * + * This store manages all application settings including AI model parameters, UI preferences, + * and theme configuration. It provides persistent storage through localStorage with reactive + * state management using Svelte 5 runes. + * + * **Architecture & Relationships:** + * - **settingsStore** (this class): Configuration state management + * - Manages AI model parameters (temperature, max tokens, etc.) + * - Handles theme switching and persistence + * - Provides localStorage synchronization + * - Offers reactive configuration access + * + * - **ChatService**: Reads model parameters for API requests + * - **UI Components**: Subscribe to theme and configuration changes + * + * **Key Features:** + * - **Model Parameters**: Temperature, max tokens, top-p, top-k, repeat penalty + * - **Theme Management**: Auto, light, dark theme switching + * - **Persistence**: Automatic localStorage synchronization + * - **Reactive State**: Svelte 5 runes for automatic UI updates + * - **Default Handling**: Graceful fallback to defaults for missing settings + * - **Batch Updates**: Efficient multi-setting updates + * - **Reset Functionality**: Restore defaults for individual or all settings + * + * **Configuration Categories:** + * - Generation parameters (temperature, tokens, sampling) + * - UI preferences (theme, display options) + * - System settings (model selection, prompts) + * - Advanced options (seed, penalties, context handling) + */ + +import { browser } from '$app/environment'; +import { ColorMode } from '$lib/enums'; +import type { SettingsExportType } from '$lib/types'; +import { setMode } from 'mode-watcher'; +import { + CONFIG_LOCALSTORAGE_KEY, + SETTING_CONFIG_DEFAULT, + SETTINGS_KEYS, + USER_OVERRIDES_LOCALSTORAGE_KEY +} from '$lib/constants'; + +import { IsMobile } from '$lib/hooks/is-mobile.svelte'; +import { ParameterSyncService } from '$lib/services/parameter-sync.service'; +import { serverStore } from '$lib/stores/server.svelte'; +import { + configToParameterRecord, + normalizeFloatingPoint, + getConfigValue, + setConfigValue +} from '$lib/utils'; + +class SettingsStore { + /** + * + * + * State + * + * + */ + + config = $state({ ...SETTING_CONFIG_DEFAULT }); + isInitialized = $state(false); + userOverrides = $state>(new Set()); + + /** + * + * + * Utilities (private helpers) + * + * + */ + + /** + * Helper method to get server defaults with null safety + * Centralizes the pattern of getting and extracting server defaults + */ + private getServerDefaults(): Record { + const serverParams = serverStore.defaultParams; + const uiSettings = serverStore.uiSettings; + + return ParameterSyncService.extractServerDefaults(serverParams, uiSettings); + } + + constructor() { + if (browser) { + this.initialize(); + } + } + + /** + * + * + * Lifecycle + * + * + */ + + /** + * Initialize the settings store by loading from localStorage + */ + initialize() { + try { + this.loadConfig(); + this.migrateLegacyTheme(); + // Apply the persisted theme from config on initial load + setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode); + this.isInitialized = true; + } catch (error) { + console.error('Failed to initialize settings store:', error); + } + } + + /** + * Load configuration from localStorage + * Returns default values for missing keys to prevent breaking changes + */ + private loadConfig() { + if (!browser) return; + + try { + const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY); + const savedVal = JSON.parse(storedConfigRaw || '{}'); + + // Merge with defaults to prevent breaking changes + this.config = { + ...SETTING_CONFIG_DEFAULT, + ...savedVal + }; + + // Default sendOnEnter to false on mobile when the user has no saved preference + if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) { + if (new IsMobile().current) { + this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false; + } + } + + // Load user overrides + const savedOverrides = JSON.parse( + localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]' + ); + this.userOverrides = new Set(savedOverrides); + } catch (error) { + console.warn('Failed to parse config from localStorage, using defaults:', error); + this.config = { ...SETTING_CONFIG_DEFAULT }; + this.userOverrides = new Set(); + } + } + + /** + * Migrate the legacy un-namespaced "theme" localStorage key into config. + * Previously theme was stored separately in localStorage("theme") — now it lives + * inside the config object alongside all other settings. + * After migration the legacy key is removed. + */ + private migrateLegacyTheme() { + if (!browser) return; + + const legacyTheme = localStorage.getItem('theme'); + if (legacyTheme) { + this.config[SETTINGS_KEYS.THEME] = legacyTheme; + localStorage.removeItem('theme'); + this.saveConfig(); + setMode(legacyTheme as ColorMode); + } + } + /** + * + * + * Config Updates + * + * + */ + + /** + * Update a specific configuration setting + * @param key - The configuration key to update + * @param value - The new value for the configuration key + */ + updateConfig(key: K, value: SettingsConfigType[K]): void { + this.config[key] = value; + + if (ParameterSyncService.canSyncParameter(key as string)) { + const propsDefaults = this.getServerDefaults(); + const propsDefault = propsDefaults[key as string]; + + if (propsDefault !== undefined) { + const normalizedValue = normalizeFloatingPoint(value); + const normalizedDefault = normalizeFloatingPoint(propsDefault); + + if (normalizedValue === normalizedDefault) { + this.userOverrides.delete(key as string); + } else { + this.userOverrides.add(key as string); + } + } + } + + this.saveConfig(); + } + + /** + * Update multiple configuration settings at once + * @param updates - Object containing the configuration updates + */ + updateMultipleConfig(updates: Partial) { + Object.assign(this.config, updates); + + const propsDefaults = this.getServerDefaults(); + + for (const [key, value] of Object.entries(updates)) { + if (ParameterSyncService.canSyncParameter(key)) { + const propsDefault = propsDefaults[key]; + + if (propsDefault !== undefined) { + const normalizedValue = normalizeFloatingPoint(value); + const normalizedDefault = normalizeFloatingPoint(propsDefault); + + if (normalizedValue === normalizedDefault) { + this.userOverrides.delete(key); + } else { + this.userOverrides.add(key); + } + } + } + } + + this.saveConfig(); + } + + /** + * Save the current configuration to localStorage + */ + private saveConfig() { + if (!browser) return; + + try { + localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(this.config)); + + localStorage.setItem( + USER_OVERRIDES_LOCALSTORAGE_KEY, + JSON.stringify(Array.from(this.userOverrides)) + ); + } catch (error) { + console.error('Failed to save config to localStorage:', error); + } + } + + /** + * Update the theme setting. + * @param newTheme - The new theme value + */ + updateTheme(newTheme: string) { + this.updateConfig(SETTINGS_KEYS.THEME, newTheme); + + setMode(newTheme as ColorMode); + } + + /** + * + * + * Reset + * + * + */ + + /** + * Reset configuration to defaults + */ + resetConfig() { + this.config = { ...SETTING_CONFIG_DEFAULT }; + + this.saveConfig(); + } + + /** + * Reset theme to default value. + * Theme is now stored inside the config object. + */ + resetTheme() { + this.updateConfig(SETTINGS_KEYS.THEME, SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME]); + + setMode(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME] as ColorMode); + } + + /** + * Reset all settings to defaults. + */ + resetAll() { + this.resetConfig(); + + this.resetTheme(); + } + + /** + * Reset a parameter to Server default (or UI default if no Server default) + */ + resetParameterToServerDefault(key: string): void { + const serverDefaults = this.getServerDefaults(); + const uiSettings = serverStore.uiSettings; + + if (uiSettings && key in uiSettings) { + // UI setting from admin config: write actual value + setConfigValue(this.config, key, uiSettings[key]); + } else if (serverDefaults[key] !== undefined) { + // sampling param known by server: clear it, let server decide + setConfigValue(this.config, key, ''); + } else if (key in SETTING_CONFIG_DEFAULT) { + setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key)); + } + + this.userOverrides.delete(key); + this.saveConfig(); + } + + /** + * + * + * Server Sync + * + * + */ + + /** + * Initialize settings with props defaults when server properties are first loaded + * This sets up the default values from /props endpoint + */ + syncWithServerDefaults(): void { + const propsDefaults = this.getServerDefaults(); + if (Object.keys(propsDefaults).length === 0) return; + + const uiSettings = serverStore.uiSettings; + const uiSettingsKeys = new Set(uiSettings ? Object.keys(uiSettings) : []); + + for (const [key, propsValue] of Object.entries(propsDefaults)) { + const currentValue = getConfigValue(this.config, key); + + const normalizedCurrent = normalizeFloatingPoint(currentValue); + const normalizedDefault = normalizeFloatingPoint(propsValue); + + // if user value matches server, it's not a real override + if (normalizedCurrent === normalizedDefault) { + this.userOverrides.delete(key); + + if (!uiSettingsKeys.has(key) && getConfigValue(SETTING_CONFIG_DEFAULT, key) === undefined) { + setConfigValue(this.config, key, undefined); + } + } + } + + // UI settings need actual values in config (no placeholder mechanism), + // so write them for non-overridden keys + if (uiSettings) { + for (const [key, value] of Object.entries(uiSettings)) { + if (!this.userOverrides.has(key) && value !== undefined) { + setConfigValue(this.config, key, value); + + // theme lives in mode-watcher, not just in config -> propagate + if (key === SETTINGS_KEYS.THEME) { + setMode(value as ColorMode); + } + } + } + } + + this.saveConfig(); + console.log('User overrides after sync:', Array.from(this.userOverrides)); + } + + /** + * Reset all parameters to their default values (from props) + * This is used by the "Reset to Default" functionality + * Prioritizes Server defaults from /props, falls back to UI defaults + */ + forceSyncWithServerDefaults(): void { + const propsDefaults = this.getServerDefaults(); + const uiSettings = serverStore.uiSettings; + + for (const key of ParameterSyncService.getSyncableParameterKeys()) { + if (uiSettings && key in uiSettings) { + // UI setting from admin config: write actual value + setConfigValue(this.config, key, uiSettings[key]); + } else if (propsDefaults[key] !== undefined) { + // sampling param: clear it, let server decide + setConfigValue(this.config, key, ''); + } else if (key in SETTING_CONFIG_DEFAULT) { + setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key)); + } + + this.userOverrides.delete(key); + } + + this.saveConfig(); + } + + /** + * + * + * Utilities + * + * + */ + + /** + * Get a specific configuration value + * @param key - The configuration key to get + * @returns The configuration value + */ + getConfig(key: K): SettingsConfigType[K] { + return this.config[key]; + } + + /** + * Get the entire configuration object + * @returns The complete configuration object + */ + getAllConfig(): SettingsConfigType { + return { ...this.config }; + } + + canSyncParameter(key: string): boolean { + return ParameterSyncService.canSyncParameter(key); + } + + /** + * Get parameter information including source for a specific parameter + */ + getParameterInfo(key: string) { + const propsDefaults = this.getServerDefaults(); + const currentValue = getConfigValue(this.config, key); + + return ParameterSyncService.getParameterInfo( + key, + currentValue ?? '', + propsDefaults, + this.userOverrides + ); + } + + /** + * Get diff between current settings and server defaults + */ + getParameterDiff() { + const serverDefaults = this.getServerDefaults(); + if (Object.keys(serverDefaults).length === 0) return {}; + + const configAsRecord = configToParameterRecord( + this.config, + ParameterSyncService.getSyncableParameterKeys() + ); + + return ParameterSyncService.createParameterDiff(configAsRecord, serverDefaults); + } + + /** + * Clear all user overrides (for debugging) + */ + clearAllUserOverrides(): void { + this.userOverrides.clear(); + this.saveConfig(); + console.log('Cleared all user overrides'); + } + + /** + * + * + * Import / Export + * + * + */ + + /** + * Export all settings as a versioned JSON-compatible object. + * The export captures the full config (excluding sensitive values like API key) + * and user overrides. Sensitive fields are filtered out for security by default. + * @param includeSensitiveData - If true, include sensitive fields (apiKey, MCP server headers) in export + */ + exportSettings(includeSensitiveData: boolean = false): SettingsExportType { + // Build config excluding sensitive data unless user opts in + const configToExport: Record = + includeSensitiveData + ? { ...this.config } + : Object.fromEntries(Object.entries(this.config).filter(([key]) => key !== 'apiKey')); + + // Handle MCP servers: exclude custom headers unless user opts in + if ('mcpServers' in configToExport && !includeSensitiveData) { + try { + const mcpServers = JSON.parse(configToExport.mcpServers as string) as Array< + Record + >; + const safeServers = mcpServers.map((server) => { + delete server.headers; + return server; + }); + configToExport.mcpServers = JSON.stringify(safeServers); + } catch { + // If parsing fails, just exclude the entire mcpServers field + delete (configToExport as Record).mcpServers; + } + } + + return { + version: 1, + timestamp: Date.now(), + config: configToExport, + userOverrides: Array.from(this.userOverrides) + }; + } + + /** + * Import settings from a previously exported object. + * Restores config (including theme) and user overrides. + * @param data - The exported settings object + */ + importSettings(data: SettingsExportType): void { + if (!browser) return; + + if (!data || !data.config) { + throw new Error('Invalid settings data: missing config'); + } + + // Restore config (theme is included in config) + this.config = { + ...SETTING_CONFIG_DEFAULT, + ...data.config + }; + + // Restore user overrides (derived state — may be stale if server defaults differ) + this.userOverrides = new Set(data.userOverrides ?? []); + + // Persist to localStorage + this.saveConfig(); + + // Apply theme for immediate visual feedback + setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode); + + console.log('Settings imported successfully'); + } +} + +export const settingsStore = new SettingsStore(); + +export const config = () => settingsStore.config; +export const theme = () => settingsStore.config[SETTINGS_KEYS.THEME]; +export const isInitialized = () => settingsStore.isInitialized; diff --git a/tools/ui/src/lib/stores/tools.svelte.ts b/tools/ui/src/lib/stores/tools.svelte.ts new file mode 100644 index 000000000..5404a7a46 --- /dev/null +++ b/tools/ui/src/lib/stores/tools.svelte.ts @@ -0,0 +1,427 @@ +import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types'; +import { ToolsService } from '$lib/services/tools.service'; +import { mcpStore } from '$lib/stores/mcp.svelte'; +import { HealthCheckStatus, JsonSchemaType, ToolCallType, ToolSource } from '$lib/enums'; +import { config } from '$lib/stores/settings.svelte'; +import { + DISABLED_TOOLS_LOCALSTORAGE_KEY, + TOOL_GROUP_LABELS, + TOOL_SERVER_LABELS +} from '$lib/constants'; + +import { SvelteSet } from 'svelte/reactivity'; + +class ToolsStore { + private _builtinTools = $state([]); + private _loading = $state(false); + private _error = $state(null); + private _disabledTools = $state(new SvelteSet()); + private _toolsEndpointUnreachable = $state(false); + + constructor() { + try { + const stored = localStorage.getItem(DISABLED_TOOLS_LOCALSTORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + for (const name of parsed) { + if (typeof name === 'string') this._disabledTools.add(name); + } + } + } + } catch (err) { + console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err); + } + + // Initialize builtin tools on startup + this.fetchBuiltinTools(); + } + + private persistDisabledTools(): void { + try { + localStorage.setItem( + DISABLED_TOOLS_LOCALSTORAGE_KEY, + JSON.stringify([...this._disabledTools]) + ); + } catch { + // ignore storage errors + } + } + + get builtinTools(): OpenAIToolDefinition[] { + return this._builtinTools; + } + + get mcpTools(): OpenAIToolDefinition[] { + return mcpStore.getToolDefinitionsForLLM(); + } + + get customTools(): OpenAIToolDefinition[] { + const raw = config().custom; + if (!raw || typeof raw !== 'string') return []; + + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + + return parsed.filter( + (t: unknown): t is OpenAIToolDefinition => + typeof t === 'object' && + t !== null && + 'type' in t && + (t as OpenAIToolDefinition).type === 'function' && + 'function' in t && + typeof (t as OpenAIToolDefinition).function?.name === 'string' + ); + } catch { + return []; + } + } + + /** Flat list of all tool entries with source metadata */ + get allTools(): ToolEntry[] { + const entries: ToolEntry[] = []; + + for (const def of this._builtinTools) { + entries.push({ source: ToolSource.BUILTIN, definition: def }); + } + + // Use live connections when available (full schema), fall back to health check data + const connections = mcpStore.getConnections(); + if (connections.size > 0) { + for (const [serverId, connection] of connections) { + const serverName = mcpStore.getServerDisplayName(serverId); + for (const tool of connection.tools) { + const rawSchema = (tool.inputSchema as Record) ?? { + type: JsonSchemaType.OBJECT, + properties: {}, + required: [] + }; + entries.push({ + source: ToolSource.MCP, + serverName, + serverId, + definition: { + type: ToolCallType.FUNCTION, + function: { + name: tool.name, + description: tool.description, + parameters: rawSchema + } + } + }); + } + } + } else { + for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) { + for (const tool of tools) { + entries.push({ + source: ToolSource.MCP, + serverName, + serverId, + definition: { + type: ToolCallType.FUNCTION, + function: { + name: tool.name, + description: tool.description, + parameters: { + type: JsonSchemaType.OBJECT, + properties: {}, + required: [] + } + } + } + }); + } + } + } + + for (const def of this.customTools) { + entries.push({ source: ToolSource.CUSTOM, definition: def }); + } + + return entries; + } + + /** Tools grouped by category for tree display */ + get toolGroups(): ToolGroup[] { + const groups: ToolGroup[] = []; + + if (this._builtinTools.length > 0) { + groups.push({ + source: ToolSource.BUILTIN, + label: TOOL_GROUP_LABELS[ToolSource.BUILTIN], + tools: this._builtinTools + }); + } + + // Use live connections when available, fall back to health check data + const connections = mcpStore.getConnections(); + if (connections.size > 0) { + for (const [serverId, connection] of connections) { + if (connection.tools.length === 0) continue; + const label = mcpStore.getServerDisplayName(serverId); + const tools: OpenAIToolDefinition[] = connection.tools.map((tool) => { + const rawSchema = (tool.inputSchema as Record) ?? { + type: JsonSchemaType.OBJECT, + properties: {}, + required: [] + }; + return { + type: ToolCallType.FUNCTION, + function: { + name: tool.name, + description: tool.description, + parameters: rawSchema + } + }; + }); + groups.push({ source: ToolSource.MCP, label, serverId, tools }); + } + } else { + for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) { + if (tools.length === 0) continue; + const defs: OpenAIToolDefinition[] = tools.map((tool) => ({ + type: ToolCallType.FUNCTION, + function: { + name: tool.name, + description: tool.description, + parameters: { type: JsonSchemaType.OBJECT, properties: {}, required: [] } + } + })); + groups.push({ source: ToolSource.MCP, label: serverName, serverId, tools: defs }); + } + } + + const custom = this.customTools; + if (custom.length > 0) { + groups.push({ + source: ToolSource.CUSTOM, + label: TOOL_GROUP_LABELS[ToolSource.CUSTOM], + tools: custom + }); + } + + return groups; + } + + /** Only enabled tool definitions (for sending to the API) */ + get enabledToolDefinitions(): OpenAIToolDefinition[] { + return this.allTools + .filter((t) => !this._disabledTools.has(t.definition.function.name)) + .map((t) => t.definition); + } + + /** + * Returns enabled tool definitions for sending to the LLM. + * MCP tools use properly normalized schemas from mcpStore. + * Filters out tools disabled via the UI checkboxes. + */ + getEnabledToolsForLLM(): OpenAIToolDefinition[] { + const disabled = this._disabledTools; + const result: OpenAIToolDefinition[] = []; + + for (const tool of this._builtinTools) { + if (!disabled.has(tool.function.name)) { + result.push(tool); + } + } + + // MCP tools with properly normalized schemas + for (const tool of mcpStore.getToolDefinitionsForLLM()) { + if (!disabled.has(tool.function.name)) { + result.push(tool); + } + } + + for (const tool of this.customTools) { + if (!disabled.has(tool.function.name)) { + result.push(tool); + } + } + + return result; + } + + get allToolDefinitions(): OpenAIToolDefinition[] { + return this.allTools.map((t) => t.definition); + } + + get loading(): boolean { + return this._loading; + } + + get error(): string | null { + return this._error; + } + + get isToolsEndpointUnreachable(): boolean { + return this._toolsEndpointUnreachable; + } + + get disabledTools(): SvelteSet { + return this._disabledTools; + } + + isToolEnabled(toolName: string): boolean { + return !this._disabledTools.has(toolName); + } + + toggleTool(toolName: string): void { + if (this._disabledTools.has(toolName)) { + this._disabledTools.delete(toolName); + } else { + this._disabledTools.add(toolName); + } + this.persistDisabledTools(); + } + + setToolEnabled(toolName: string, enabled: boolean): void { + if (enabled) { + this._disabledTools.delete(toolName); + } else { + this._disabledTools.add(toolName); + } + } + + /** + * Enable all tools belonging to a specific MCP server. + * Called when a server is enabled for a conversation. + */ + enableAllToolsForServer(serverId: string): void { + const connection = mcpStore.getConnections().get(serverId); + if (!connection) return; + for (const tool of connection.tools) { + this._disabledTools.delete(tool.name); + } + this.persistDisabledTools(); + } + + toggleGroup(group: ToolGroup): void { + const allEnabled = group.tools.every((t) => this.isToolEnabled(t.function.name)); + for (const tool of group.tools) { + this.setToolEnabled(tool.function.name, !allEnabled); + } + this.persistDisabledTools(); + } + + isGroupFullyEnabled(group: ToolGroup): boolean { + return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.function.name)); + } + + isGroupPartiallyEnabled(group: ToolGroup): boolean { + const enabledCount = group.tools.filter((t) => this.isToolEnabled(t.function.name)).length; + return enabledCount > 0 && enabledCount < group.tools.length; + } + + /** + * Get MCP tools from health check data (reactive). + * Used when live connections aren't established yet. + */ + private getMcpToolsFromHealthChecks(): { + serverId: string; + serverName: string; + tools: { name: string; description?: string }[]; + }[] { + const result: ReturnType = []; + for (const server of mcpStore.getServersSorted().filter((s) => s.enabled)) { + const health = mcpStore.getHealthCheckState(server.id); + if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) { + result.push({ + serverId: server.id, + serverName: mcpStore.getServerLabel(server), + tools: health.tools + }); + } + } + return result; + } + + /** Determine the source of a tool by its name. */ + getToolSource(toolName: string): ToolSource | null { + if (this._builtinTools.some((t) => t.function.name === toolName)) { + return ToolSource.BUILTIN; + } + for (const entry of this.allTools) { + if (entry.definition.function.name === toolName) { + return entry.source; + } + } + return null; + } + + /** Get the display label for the server that owns a given tool. */ + getToolServerLabel(toolName: string): string { + for (const entry of this.allTools) { + if (entry.definition.function.name === toolName) { + if (entry.serverName) { + return mcpStore.getServerDisplayName(entry.serverName); + } + if (entry.source === ToolSource.BUILTIN) { + return TOOL_SERVER_LABELS[ToolSource.BUILTIN]; + } + if (entry.source === ToolSource.CUSTOM) { + return TOOL_SERVER_LABELS[ToolSource.CUSTOM]; + } + } + } + return ''; + } + + /** Build a permission key with category prefix, e.g. "mcp-:tool_name" */ + getPermissionKey(toolName: string): string | null { + for (const entry of this.allTools) { + if (entry.definition.function.name === toolName) { + switch (entry.source) { + case ToolSource.BUILTIN: + return `builtin:${toolName}`; + case ToolSource.CUSTOM: + return `custom:${toolName}`; + case ToolSource.MCP: + if (entry.serverId) { + return `mcp-${entry.serverId}:${toolName}`; + } + return `mcp:${toolName}`; + default: + return null; + } + } + } + return null; + } + + /** Check if there are any enabled tools available (builtin, MCP, or custom). */ + get hasEnabledTools(): boolean { + return this.getEnabledToolsForLLM().length > 0; + } + + async fetchBuiltinTools(): Promise { + if (this._loading) return; + + this._loading = true; + this._error = null; + this._toolsEndpointUnreachable = false; + + try { + const toolInfos = await ToolsService.list(); + this._builtinTools = toolInfos.map((info) => info.definition); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + this._error = errorMessage; + // 404 from /tools means the server was started without --tools + if (errorMessage.includes('404') || errorMessage.toLowerCase().includes('not found')) { + this._toolsEndpointUnreachable = true; + } + console.error('[ToolsStore] Failed to fetch built-in tools:', err); + } finally { + this._loading = false; + } + } +} + +export const toolsStore = new ToolsStore(); + +export const allTools = () => toolsStore.allTools; +export const allToolDefinitions = () => toolsStore.allToolDefinitions; +export const enabledToolDefinitions = () => toolsStore.enabledToolDefinitions; +export const toolGroups = () => toolsStore.toolGroups; diff --git a/tools/ui/src/lib/types/agentic.d.ts b/tools/ui/src/lib/types/agentic.d.ts new file mode 100644 index 000000000..b94998384 --- /dev/null +++ b/tools/ui/src/lib/types/agentic.d.ts @@ -0,0 +1,158 @@ +import type { MessageRole } from '$lib/enums'; +import { ToolCallType } from '$lib/enums'; +import type { + ApiChatCompletionRequest, + ApiChatCompletionToolCall, + ApiChatMessageContentPart, + ApiChatMessageData +} from './api'; +import type { ChatMessageTimings, ChatMessagePromptProgress } from './chat'; +import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from './database'; + +/** + * Agentic orchestration configuration. + */ +export interface AgenticConfig { + enabled: boolean; + maxTurns: number; + maxToolPreviewLines: number; +} + +/** + * Tool call payload for agentic messages. + */ +export type AgenticToolCallPayload = { + id: string; + type: ToolCallType.FUNCTION; + function: { + name: string; + arguments: string; + }; +}; + +/** + * Agentic message types for different roles. + */ +export type AgenticMessage = + | { + role: MessageRole.SYSTEM | MessageRole.USER; + content: string | ApiChatMessageContentPart[]; + } + | { + role: MessageRole.ASSISTANT; + content?: string | ApiChatMessageContentPart[]; + reasoning_content?: string; + tool_calls?: AgenticToolCallPayload[]; + } + | { + role: MessageRole.TOOL; + tool_call_id: string; + content: string | ApiChatMessageContentPart[]; + }; + +export type AgenticAssistantMessage = Extract; +export type AgenticToolCallList = NonNullable; + +export type AgenticChatCompletionRequest = Omit & { + messages: AgenticMessage[]; + stream: true; + tools?: ApiChatCompletionRequest['tools']; +}; + +/** + * Per-conversation agentic session state. + * Enables parallel agentic flows across multiple chats. + */ +export interface AgenticSession { + isRunning: boolean; + currentTurn: number; + totalToolCalls: number; + lastError: Error | null; + streamingToolCall: { name: string; arguments: string } | null; + pendingPermissionRequest: { toolName: string; serverLabel: string } | null; +} + +/** + * Callbacks for agentic flow execution. + * + * The agentic loop creates separate DB messages for each turn: + * - assistant messages (one per LLM turn, with tool_calls if any) + * - tool result messages (one per tool call execution) + * + * The first assistant message is created by the caller before starting the flow. + * Subsequent messages are created via createToolResultMessage / createAssistantMessage. + */ +export interface AgenticFlowCallbacks { + /** Content chunk for the current assistant message */ + onChunk?: (chunk: string) => void; + /** Reasoning content chunk for the current assistant message */ + onReasoningChunk?: (chunk: string) => void; + /** Tool calls being streamed (partial, accumulating) for the current turn */ + onToolCallsStreaming?: (toolCalls: ApiChatCompletionToolCall[]) => void; + /** Attachments extracted from tool results */ + onAttachments?: (messageId: string, extras: DatabaseMessageExtra[]) => void; + /** Model name detected from response */ + onModel?: (model: string) => void; + /** Current assistant turn's streaming is complete - save to DB */ + onAssistantTurnComplete?: ( + content: string, + reasoningContent: string | undefined, + timings: ChatMessageTimings | undefined, + toolCalls: ApiChatCompletionToolCall[] | undefined + ) => Promise; + /** Create a tool result message in the DB tree */ + createToolResultMessage?: ( + toolCallId: string, + content: string, + extras?: DatabaseMessageExtra[] + ) => Promise; + /** Create a new assistant message for the next agentic turn */ + createAssistantMessage?: () => Promise; + /** Entire agentic flow is complete */ + onFlowComplete?: (timings?: ChatMessageTimings) => void; + /** Error during flow */ + onError?: (error: Error) => void; + /** Timing updates during streaming */ + onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void; + /** An agentic turn (LLM + tool execution) completed - intermediate timing update */ + onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void; +} + +/** + * Options for agentic flow execution + */ +export interface AgenticFlowOptions { + stream?: boolean; + model?: string; + temperature?: number; + max_tokens?: number; + [key: string]: unknown; +} + +/** + * Parameters for starting an agentic flow + */ +export interface AgenticFlowParams { + conversationId: string; + messages: (ApiChatMessageData | (DatabaseMessage & { extra?: DatabaseMessageExtra[] }))[]; + options?: AgenticFlowOptions; + callbacks: AgenticFlowCallbacks; + signal?: AbortSignal; + perChatOverrides?: McpServerOverride[]; +} + +/** + * Result of an agentic flow execution + */ +export interface AgenticFlowResult { + handled: boolean; + error?: Error; +} + +/** + * A user message to be injected into the agentic loop between turns. + */ +export interface SteeringMessage { + content: string; + extras?: DatabaseMessageExtra[]; +} diff --git a/tools/ui/src/lib/types/api.d.ts b/tools/ui/src/lib/types/api.d.ts new file mode 100644 index 000000000..808c44203 --- /dev/null +++ b/tools/ui/src/lib/types/api.d.ts @@ -0,0 +1,459 @@ +import type { ContentPartType, ServerModelStatus, ServerRole } from '$lib/enums'; +import type { ChatMessagePromptProgress, ChatRole } from './chat'; + +export interface ApiChatCompletionToolFunction { + name: string; + description?: string; + parameters: Record; +} + +export interface ApiChatCompletionTool { + type: 'function'; + function: ApiChatCompletionToolFunction; +} + +export interface ApiChatMessageContentPart { + type: ContentPartType; + text?: string; + image_url?: { + url: string; + }; + input_audio?: { + data: string; + format: 'wav' | 'mp3'; + }; +} + +export interface ApiContextSizeError { + code: number; + message: string; + type: 'exceed_context_size_error'; + n_prompt_tokens: number; + n_ctx: number; +} + +export interface ApiErrorResponse { + error: + | ApiContextSizeError + | { + code: number; + message: string; + type?: string; + }; +} + +export interface ApiChatMessageData { + role: ChatRole; + content: string | ApiChatMessageContentPart[]; + reasoning_content?: string; + tool_calls?: ApiChatCompletionToolCall[]; + tool_call_id?: string; + timestamp?: number; +} + +/** + * Model status object from /models endpoint + */ +export interface ApiModelStatus { + /** Status value: loaded, unloaded, loading, sleeping, failed */ + value: ServerModelStatus; + /** Command line arguments used when loading (only for loaded models) */ + args?: string[]; +} + +/** + * Model entry from /models endpoint (ROUTER mode) + * Based on actual API response structure + */ +export interface ApiModelDataEntry { + /** Model identifier (e.g., "ggml-org/Qwen2.5-Omni-7B-GGUF:latest") */ + id: string; + /** Model name (optional, usually same as id - not always returned by API) */ + name?: string; + /** Object type, always "model" */ + object: string; + /** Owner, usually "llamacpp" */ + owned_by: string; + /** Creation timestamp */ + created: number; + /** Whether model files are in HuggingFace cache */ + in_cache: boolean; + /** Path to model manifest file */ + path: string; + /** Current status of the model */ + status: ApiModelStatus; + /** Alternative names that resolve to this model */ + aliases?: string[]; + /** Informational tags for this model */ + tags?: string[]; + /** Legacy meta field (may be present in older responses) */ + meta?: Record | null; +} + +export interface ApiModelDetails { + name: string; + model: string; + modified_at?: string; + size?: string | number; + digest?: string; + type?: string; + description?: string; + tags?: string[]; + capabilities?: string[]; + parameters?: string; + details?: { + parent_model?: string; + format?: string; + family?: string; + families?: string[]; + parameter_size?: string; + quantization_level?: string; + }; +} + +export interface ApiModelListResponse { + object: string; + data: ApiModelDataEntry[]; + models?: ApiModelDetails[]; +} + +export interface ApiLlamaCppServerProps { + default_generation_settings: { + id: number; + id_task: number; + n_ctx: number; + speculative: boolean; + is_processing: boolean; + params: { + n_predict: number; + seed: number; + temperature: number; + dynatemp_range: number; + dynatemp_exponent: number; + top_k: number; + top_p: number; + min_p: number; + top_n_sigma: number; + xtc_probability: number; + xtc_threshold: number; + typ_p: number; + repeat_last_n: number; + repeat_penalty: number; + presence_penalty: number; + frequency_penalty: number; + dry_multiplier: number; + dry_base: number; + dry_allowed_length: number; + dry_penalty_last_n: number; + dry_sequence_breakers: string[]; + mirostat: number; + mirostat_tau: number; + mirostat_eta: number; + stop: string[]; + max_tokens: number; + n_keep: number; + n_discard: number; + ignore_eos: boolean; + stream: boolean; + logit_bias: Array<[number, number]>; + n_probs: number; + min_keep: number; + grammar: string; + grammar_lazy: boolean; + grammar_triggers: string[]; + preserved_tokens: number[]; + chat_format: string; + reasoning_format: string; + reasoning_in_content: boolean; + generation_prompt: string; + samplers: string[]; + backend_sampling: boolean; + 'speculative.n_max': number; + 'speculative.n_min': number; + 'speculative.p_min': number; + timings_per_token: boolean; + post_sampling_probs: boolean; + lora: Array<{ name: string; scale: number }>; + }; + prompt: string; + next_token: { + has_next_token: boolean; + has_new_line: boolean; + n_remain: number; + n_decoded: number; + stopping_word: string; + }; + }; + total_slots: number; + model_path: string; + role: ServerRole; + modalities: { + vision: boolean; + audio: boolean; + }; + chat_template: string; + bos_token: string; + eos_token: string; + build_info: string; + /** @deprecated Use {@link ui_settings} instead */ + webui_settings?: Record; + ui_settings?: Record; +} + +export interface ApiChatCompletionRequest { + messages: Array<{ + role: ChatRole; + content: string | ApiChatMessageContentPart[]; + reasoning_content?: string; + tool_calls?: ApiChatCompletionToolCall[]; + tool_call_id?: string; + }>; + stream?: boolean; + model?: string; + return_progress?: boolean; + tools?: ApiChatCompletionTool[]; + // Reasoning parameters + reasoning_format?: string; + // Generation parameters + temperature?: number; + max_tokens?: number; + // Sampling parameters + dynatemp_range?: number; + dynatemp_exponent?: number; + top_k?: number; + top_p?: number; + min_p?: number; + xtc_probability?: number; + xtc_threshold?: number; + typ_p?: number; + // Penalty parameters + repeat_last_n?: number; + repeat_penalty?: number; + presence_penalty?: number; + frequency_penalty?: number; + dry_multiplier?: number; + dry_base?: number; + dry_allowed_length?: number; + dry_penalty_last_n?: number; + // Sampler configuration + samplers?: string[]; + backend_sampling?: boolean; + // Custom parameters (JSON string) + custom?: Record; + timings_per_token?: boolean; + // Continuation control (vLLM compat) + add_generation_prompt?: boolean; + continue_final_message?: boolean; +} + +export interface ApiChatCompletionToolCallFunctionDelta { + name?: string; + arguments?: string; +} + +export interface ApiChatCompletionToolCallDelta { + index?: number; + id?: string; + type?: string; + function?: ApiChatCompletionToolCallFunctionDelta; +} + +export interface ApiChatCompletionToolCall extends ApiChatCompletionToolCallDelta { + function?: ApiChatCompletionToolCallFunctionDelta & { arguments?: string }; +} + +export interface ApiChatCompletionStreamChunk { + object?: string; + model?: string; + choices: Array<{ + model?: string; + metadata?: { model?: string }; + delta: { + content?: string; + reasoning_content?: string; + model?: string; + tool_calls?: ApiChatCompletionToolCallDelta[]; + }; + finish_reason?: string | null; + }>; + timings?: { + prompt_n?: number; + prompt_ms?: number; + predicted_n?: number; + predicted_ms?: number; + cache_n?: number; + }; + prompt_progress?: ChatMessagePromptProgress; +} + +export interface ApiChatCompletionResponse { + model?: string; + choices: Array<{ + model?: string; + metadata?: { model?: string }; + message: { + content: string; + reasoning_content?: string; + model?: string; + tool_calls?: ApiChatCompletionToolCall[]; + }; + finish_reason?: string | null; + }>; +} + +export interface ApiSlotData { + id: number; + id_task: number; + n_ctx: number; + speculative: boolean; + is_processing: boolean; + params: { + n_predict: number; + seed: number; + temperature: number; + dynatemp_range: number; + dynatemp_exponent: number; + top_k: number; + top_p: number; + min_p: number; + top_n_sigma: number; + xtc_probability: number; + xtc_threshold: number; + typical_p: number; + repeat_last_n: number; + repeat_penalty: number; + presence_penalty: number; + frequency_penalty: number; + dry_multiplier: number; + dry_base: number; + dry_allowed_length: number; + dry_penalty_last_n: number; + mirostat: number; + mirostat_tau: number; + mirostat_eta: number; + max_tokens: number; + n_keep: number; + n_discard: number; + ignore_eos: boolean; + stream: boolean; + n_probs: number; + min_keep: number; + chat_format: string; + reasoning_format: string; + reasoning_in_content: boolean; + generation_prompt: string; + samplers: string[]; + backend_sampling: boolean; + 'speculative.n_max': number; + 'speculative.n_min': number; + 'speculative.p_min': number; + timings_per_token: boolean; + post_sampling_probs: boolean; + lora: Array<{ name: string; scale: number }>; + }; + next_token: { + has_next_token: boolean; + has_new_line: boolean; + n_remain: number; + n_decoded: number; + }; +} + +export interface ApiProcessingState { + status: 'initializing' | 'generating' | 'preparing' | 'idle'; + tokensDecoded: number; + tokensRemaining: number; + contextUsed: number; + contextTotal: number | null; + outputTokensUsed: number; // Total output tokens (thinking + regular content) + outputTokensMax: number; // Max output tokens allowed + temperature: number; + topP: number; + speculative: boolean; + hasNextToken: boolean; + tokensPerSecond?: number; + // Progress information from prompt_progress + progressPercent?: number; + promptProgress?: ChatMessagePromptProgress; + promptTokens?: number; + promptMs?: number; + cacheTokens?: number; +} + +/** + * Router model metadata - extended from ApiModelDataEntry with additional router-specific fields + * @deprecated Use ApiModelDataEntry instead - the /models endpoint returns this structure directly + */ +export interface ApiRouterModelMeta { + /** Model identifier (e.g., "ggml-org/Qwen2.5-Omni-7B-GGUF:latest") */ + name: string; + /** Path to model file or manifest */ + path: string; + /** Optional path to multimodal projector */ + path_mmproj?: string; + /** Whether model is in HuggingFace cache */ + in_cache: boolean; + /** Port where model instance is running (0 if not loaded) */ + port?: number; + /** Current status of the model */ + status: ApiModelStatus; + /** Error message if status is FAILED */ + error?: string; +} + +/** + * Request to load a model + */ +export interface ApiRouterModelsLoadRequest { + model: string; +} + +/** + * Response from loading a model + */ +export interface ApiRouterModelsLoadResponse { + success: boolean; + error?: string; +} + +/** + * Request to check model status + */ +export interface ApiRouterModelsStatusRequest { + model: string; +} + +/** + * Response with model status + */ +export interface ApiRouterModelsStatusResponse { + model: string; + status: ModelStatus; + port?: number; + error?: string; +} + +/** + * Response with list of all models from /models endpoint + * Note: This is the same as ApiModelListResponse - the endpoint returns the same structure + * regardless of server mode (MODEL or ROUTER) + */ +export interface ApiRouterModelsListResponse { + object: string; + data: ApiModelDataEntry[]; +} + +/** + * Request to unload a model + */ +export interface ApiRouterModelsUnloadRequest { + model: string; +} + +/** + * Response from unloading a model + */ +export interface ApiRouterModelsUnloadResponse { + success: boolean; + error?: string; +} diff --git a/tools/ui/src/lib/types/chat.d.ts b/tools/ui/src/lib/types/chat.d.ts new file mode 100644 index 000000000..acedd0769 --- /dev/null +++ b/tools/ui/src/lib/types/chat.d.ts @@ -0,0 +1,161 @@ +import type { ErrorDialogType } from '$lib/enums'; +import type { ApiChatCompletionToolCall } from './api'; +import type { DatabaseMessage, DatabaseMessageExtra } from './database'; + +export interface ChatUploadedFile { + id: string; + name: string; + size: number; + type: string; + file: File; + preview?: string; + textContent?: string; + mcpPrompt?: { + serverName: string; + promptName: string; + arguments?: Record; + }; + isLoading?: boolean; + loadError?: string; +} + +export interface ChatAttachmentDisplayItem { + id: string; + name: string; + size?: number; + preview?: string; + isImage: boolean; + isLoading?: boolean; + loadError?: string; + uploadedFile?: ChatUploadedFile; + attachment?: DatabaseMessageExtra; + attachmentIndex?: number; + textContent?: string; +} + +export interface ChatMessageSiblingInfo { + message: DatabaseMessage; + siblingIds: string[]; + currentIndex: number; + totalSiblings: number; +} + +export interface ChatMessagePromptProgress { + cache: number; + processed: number; + time_ms: number; + total: number; +} + +export interface ChatMessageTimings { + cache_n?: number; + predicted_ms?: number; + predicted_n?: number; + prompt_ms?: number; + prompt_n?: number; + agentic?: ChatMessageAgenticTimings; +} + +export interface ChatMessageAgenticTimings { + turns: number; + toolCallsCount: number; + toolsMs: number; + toolCalls?: ChatMessageToolCallTiming[]; + perTurn?: ChatMessageAgenticTurnStats[]; + llm: { + predicted_n: number; + predicted_ms: number; + prompt_n: number; + prompt_ms: number; + }; +} + +export interface ChatMessageAgenticTurnStats { + turn: number; + llm: { + predicted_n: number; + predicted_ms: number; + prompt_n: number; + prompt_ms: number; + }; + toolCalls: ChatMessageToolCallTiming[]; + toolsMs: number; +} + +export interface ChatMessageToolCallTiming { + name: string; + duration_ms: number; + success: boolean; +} + +/** + * Callbacks for streaming chat responses (used by both agentic and non-agentic paths) + */ +export interface ChatStreamCallbacks { + onChunk?: (chunk: string) => void; + onReasoningChunk?: (chunk: string) => void; + onToolCallsStreaming?: (toolCalls: ApiChatCompletionToolCall[]) => void; + onAttachments?: (messageId: string, extras: DatabaseMessageExtra[]) => void; + onModel?: (model: string) => void; + onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void; + onAssistantTurnComplete?: ( + content: string, + reasoningContent: string | undefined, + timings: ChatMessageTimings | undefined, + toolCalls: ApiChatCompletionToolCall[] | undefined + ) => Promise; + createToolResultMessage?: ( + toolCallId: string, + content: string, + extras?: DatabaseMessageExtra[] + ) => Promise; + createAssistantMessage?: () => Promise; + onFlowComplete?: (timings?: ChatMessageTimings) => void; + onError?: (error: Error) => void; + onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void; +} + +/** + * Error dialog state for displaying server/timeout errors + */ +export interface ErrorDialogState { + type: ErrorDialogType; + message: string; + contextInfo?: { n_prompt_tokens: number; n_ctx: number }; +} + +/** + * Live processing stats during prompt evaluation + */ +export interface LiveProcessingStats { + tokensProcessed: number; + totalTokens: number; + timeMs: number; + tokensPerSecond: number; + etaSecs?: number; +} + +/** + * Live generation stats during token generation + */ +export interface LiveGenerationStats { + tokensGenerated: number; + timeMs: number; + tokensPerSecond: number; +} + +/** + * Options for getting attachment display items + */ +export interface AttachmentDisplayItemsOptions { + uploadedFiles?: ChatUploadedFile[]; + attachments?: DatabaseMessageExtra[]; +} + +/** + * Result of file processing operation + */ +export interface FileProcessingResult { + extras: DatabaseMessageExtra[]; + emptyFiles: string[]; +} diff --git a/tools/ui/src/lib/types/common.d.ts b/tools/ui/src/lib/types/common.d.ts new file mode 100644 index 000000000..453d0cd74 --- /dev/null +++ b/tools/ui/src/lib/types/common.d.ts @@ -0,0 +1,67 @@ +import type { AttachmentType } from '$lib/enums'; + +/** + * Common utility types used across the application + */ + +/** + * Common utility types used across the application + */ + +/** + * Represents a key-value pair. + * Used for headers, environment variables, query parameters, etc. + */ +export interface KeyValuePair { + key: string; + value: string; +} + +/** + * Binary detection configuration options + */ +export interface BinaryDetectionOptions { + /** Number of characters to check from the beginning of the file */ + prefixLength: number; + /** Maximum ratio of suspicious characters allowed (0.0 to 1.0) */ + suspiciousCharThresholdRatio: number; + /** Maximum absolute number of null bytes allowed */ + maxAbsoluteNullBytes: number; +} + +/** + * Format for text attachments when copied to clipboard + */ +export interface ClipboardTextAttachment { + type: typeof AttachmentType.TEXT; + name: string; + content: string; +} + +/** + * Format for MCP prompt attachments when copied to clipboard + */ +export interface ClipboardMcpPromptAttachment { + type: typeof AttachmentType.MCP_PROMPT; + name: string; + serverName: string; + promptName: string; + content: string; + arguments?: Record; +} + +/** + * Union type for all clipboard attachment types + */ +export type ClipboardAttachment = ClipboardTextAttachment | ClipboardMcpPromptAttachment; + +/** + * Parsed result from clipboard content + */ +export interface ParsedClipboardContent { + message: string; + textAttachments: ClipboardTextAttachment[]; + mcpPromptAttachments: ClipboardMcpPromptAttachment[]; +} + +export type MimeTypeUnion = MimeTypeAudio | MimeTypeImage | MimeTypeApplication | MimeTypeText; diff --git a/tools/ui/src/lib/types/database.d.ts b/tools/ui/src/lib/types/database.d.ts new file mode 100644 index 000000000..044bd27ac --- /dev/null +++ b/tools/ui/src/lib/types/database.d.ts @@ -0,0 +1,119 @@ +import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat'; +import { AttachmentType } from '$lib/enums'; + +export interface McpServerOverride { + serverId: string; + enabled: boolean; +} + +export interface DatabaseConversation { + currNode: string | null; + id: string; + lastModified: number; + name: string; + mcpServerOverrides?: McpServerOverride[]; + forkedFromConversationId?: string; +} + +export interface DatabaseMessageExtraAudioFile { + type: AttachmentType.AUDIO; + name: string; + size?: number; + base64Data: string; + mimeType: string; +} + +export interface DatabaseMessageExtraImageFile { + type: AttachmentType.IMAGE; + name: string; + size?: number; + base64Url: string; +} + +/** + * Legacy format from the old UI — pasted content was stored as "context" type + * @deprecated Use DatabaseMessageExtraTextFile instead + */ +export interface DatabaseMessageExtraLegacyContext { + type: AttachmentType.LEGACY_CONTEXT; + name: string; + size?: number; + content: string; +} + +export interface DatabaseMessageExtraPdfFile { + type: AttachmentType.PDF; + base64Data: string; + name: string; + size?: number; + content: string; + images?: string[]; + processedAsImages: boolean; +} + +export interface DatabaseMessageExtraTextFile { + type: AttachmentType.TEXT; + name: string; + size?: number; + content: string; +} + +export interface DatabaseMessageExtraMcpPrompt { + type: AttachmentType.MCP_PROMPT; + name: string; + size?: number; + serverName: string; + promptName: string; + content: string; + arguments?: Record; +} + +export interface DatabaseMessageExtraMcpResource { + type: AttachmentType.MCP_RESOURCE; + name: string; + size?: number; + uri: string; + serverName: string; + content: string; + mimeType?: string; +} + +export type DatabaseMessageExtra = + | DatabaseMessageExtraImageFile + | DatabaseMessageExtraTextFile + | DatabaseMessageExtraAudioFile + | DatabaseMessageExtraPdfFile + | DatabaseMessageExtraMcpPrompt + | DatabaseMessageExtraMcpResource + | DatabaseMessageExtraLegacyContext; + +export interface DatabaseMessage { + id: string; + convId: string; + type: ChatMessageType; + timestamp: number; + role: ChatRole; + content: string; + parent: string | null; + /** + * @deprecated - left for backward compatibility + */ + thinking?: string; + /** Reasoning content produced by the model (separate from visible content) */ + reasoningContent?: string; + /** Serialized JSON array of tool calls made by assistant messages */ + toolCalls?: string; + /** Tool call ID for tool result messages (role: 'tool') */ + toolCallId?: string; + children: string[]; + extra?: DatabaseMessageExtra[]; + timings?: ChatMessageTimings; + model?: string; +} + +export type ExportedConversation = { + conv: DatabaseConversation; + messages: DatabaseMessage[]; +}; + +export type ExportedConversations = ExportedConversation | ExportedConversation[]; diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts new file mode 100644 index 000000000..03cb9c5a5 --- /dev/null +++ b/tools/ui/src/lib/types/index.ts @@ -0,0 +1,162 @@ +/** + * Unified exports for all type definitions + * Import types from '$lib/types' for cleaner imports + */ + +// API types +export type { + ApiChatMessageContentPart, + ApiContextSizeError, + ApiErrorResponse, + ApiChatMessageData, + ApiModelStatus, + ApiModelDataEntry, + ApiModelDetails, + ApiModelListResponse, + ApiLlamaCppServerProps, + ApiChatCompletionRequest, + ApiChatCompletionToolCallFunctionDelta, + ApiChatCompletionToolCallDelta, + ApiChatCompletionToolCall, + ApiChatCompletionStreamChunk, + ApiChatCompletionResponse, + ApiSlotData, + ApiProcessingState, + ApiRouterModelMeta, + ApiRouterModelsLoadRequest, + ApiRouterModelsLoadResponse, + ApiRouterModelsStatusRequest, + ApiRouterModelsStatusResponse, + ApiRouterModelsListResponse, + ApiRouterModelsUnloadRequest, + ApiRouterModelsUnloadResponse +} from './api'; + +// Chat types +export type { + ChatUploadedFile, + ChatAttachmentDisplayItem, + ChatMessageSiblingInfo, + ChatMessagePromptProgress, + ChatMessageTimings, + ChatMessageAgenticTimings, + ChatMessageAgenticTurnStats, + ChatMessageToolCallTiming, + ChatStreamCallbacks, + ErrorDialogState, + LiveProcessingStats, + LiveGenerationStats, + AttachmentDisplayItemsOptions, + FileProcessingResult +} from './chat.d'; + +// Database types +export type { + McpServerOverride, + DatabaseConversation, + DatabaseMessageExtraAudioFile, + DatabaseMessageExtraImageFile, + DatabaseMessageExtraLegacyContext, + DatabaseMessageExtraMcpPrompt, + DatabaseMessageExtraMcpResource, + DatabaseMessageExtraPdfFile, + DatabaseMessageExtraTextFile, + DatabaseMessageExtra, + DatabaseMessage, + ExportedConversation, + ExportedConversations +} from './database'; + +// Model types +export type { ModelModalities, ModelOption, ModalityCapabilities } from './models'; + +// Settings types +export type { + SettingsConfigValue, + SettingsFieldConfig, + SettingsChatServiceOptions, + SettingsConfigType, + SettingsExportType, + ParameterValue, + ParameterRecord, + ParameterInfo, + SyncableParameter, + SettingsEntry, + SettingsSectionTitle, + SettingsSectionEntry, + SettingsSection +} from './settings'; + +// Common types +export type { + KeyValuePair, + BinaryDetectionOptions, + ClipboardTextAttachment, + ClipboardMcpPromptAttachment, + ClipboardAttachment, + ParsedClipboardContent +} from './common'; + +// MCP types +export type { + ClientCapabilities, + ServerCapabilities, + Implementation, + MCPConnectionLog, + MCPServerInfo, + MCPCapabilitiesInfo, + MCPToolInfo, + MCPPromptInfo, + MCPConnectionDetails, + MCPPhaseCallback, + MCPConnection, + HealthCheckState, + HealthCheckParams, + MCPServerConfig, + MCPClientConfig, + MCPServerSettingsEntry, + MCPToolCall, + OpenAIToolDefinition, + ServerStatus, + ToolCallParams, + ToolExecutionResult, + ServerBuiltinToolInfo, + Tool, + Prompt, + GetPromptResult, + PromptMessage, + MCPProgressState, + MCPResourceAnnotations, + MCPResourceIcon, + MCPResource, + MCPResourceTemplate, + MCPTextResourceContent, + MCPBlobResourceContent, + MCPResourceContent, + MCPReadResourceResult, + MCPResourceInfo, + MCPResourceTemplateInfo, + MCPCachedResource, + MCPResourceAttachment, + MCPResourceSubscription, + MCPServerResources +} from './mcp'; + +// Agentic types +export type { + AgenticConfig, + AgenticToolCallPayload, + AgenticMessage, + AgenticAssistantMessage, + AgenticToolCallList, + AgenticChatCompletionRequest, + AgenticSession, + AgenticFlowCallbacks, + AgenticFlowOptions, + AgenticFlowParams, + AgenticFlowResult, + SteeringMessage +} from './agentic'; + +// Tools types +export type { ToolEntry, ToolGroup } from './tools'; diff --git a/tools/ui/src/lib/types/mcp.d.ts b/tools/ui/src/lib/types/mcp.d.ts new file mode 100644 index 000000000..3837bcdf1 --- /dev/null +++ b/tools/ui/src/lib/types/mcp.d.ts @@ -0,0 +1,432 @@ +import type { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums/mcp'; +import type { ToolSource } from '$lib/enums/tools'; +import type { + Client, + ClientCapabilities as SDKClientCapabilities, + ServerCapabilities as SDKServerCapabilities, + Implementation as SDKImplementation, + Tool, + CallToolResult, + Prompt, + GetPromptResult, + PromptMessage, + Transport +} from '@modelcontextprotocol/sdk'; +import type { MimeTypeUnion } from './common'; +import type { ColorMode } from '$lib/enums'; + +export type { Tool, CallToolResult, Prompt, GetPromptResult, PromptMessage }; +export type ClientCapabilities = SDKClientCapabilities; +export type ServerCapabilities = SDKServerCapabilities; +export type Implementation = SDKImplementation; + +/** + * Log entry for connection events + */ +export interface MCPConnectionLog { + timestamp: Date; + phase: MCPConnectionPhase; + message: string; + details?: unknown; + level: MCPLogLevel; +} + +/** + * Server information returned after initialization + */ +export interface MCPServerInfo { + name: string; + version: string; + title?: string; + description?: string; + websiteUrl?: string; + icons?: MCPResourceIcon[]; +} + +/** + * Detailed capabilities information + */ +export interface MCPCapabilitiesInfo { + server: { + tools?: { listChanged?: boolean }; + prompts?: { listChanged?: boolean }; + resources?: { subscribe?: boolean; listChanged?: boolean }; + logging?: boolean; + completions?: boolean; + tasks?: boolean; + }; + client: { + roots?: { listChanged?: boolean }; + sampling?: boolean; + elicitation?: { form?: boolean; url?: boolean }; + tasks?: boolean; + }; +} + +/** + * Tool information for display + */ +export interface MCPToolInfo { + name: string; + description?: string; + title?: string; +} + +/** + * Prompt information for display + */ +export interface MCPPromptInfo { + name: string; + description?: string; + title?: string; + serverName: string; + arguments?: Array<{ + name: string; + description?: string; + required?: boolean; + }>; +} + +/** + * Full connection details for visualization + */ +export interface MCPConnectionDetails { + phase: MCPConnectionPhase; + transportType?: MCPTransportType; + protocolVersion?: string; + serverInfo?: MCPServerInfo; + capabilities?: MCPCapabilitiesInfo; + instructions?: string; + tools: MCPToolInfo[]; + connectionTimeMs?: number; + error?: string; + logs: MCPConnectionLog[]; +} + +/** + * Callback for connection phase changes + */ +export type MCPPhaseCallback = ( + phase: MCPConnectionPhase, + log: MCPConnectionLog, + details?: { + transportType?: MCPTransportType; + serverInfo?: MCPServerInfo; + serverCapabilities?: ServerCapabilities; + clientCapabilities?: ClientCapabilities; + protocolVersion?: string; + instructions?: string; + } +) => void; + +/** + * Represents an active MCP server connection. + * Returned by MCPService.connect() and used for subsequent operations. + */ +export interface MCPConnection { + client: Client; + transport: Transport; + tools: Tool[]; + serverName: string; + transportType: MCPTransportType; + serverInfo?: MCPServerInfo; + serverCapabilities?: ServerCapabilities; + clientCapabilities?: ClientCapabilities; + protocolVersion?: string; + instructions?: string; + connectionTimeMs: number; +} + +/** + * Extended health check state with detailed connection info + */ +export type HealthCheckState = + | { status: HealthCheckStatus.IDLE } + | { + status: HealthCheckStatus.CONNECTING; + phase: MCPConnectionPhase; + logs: MCPConnectionLog[]; + } + | { + status: HealthCheckStatus.ERROR; + message: string; + phase?: MCPConnectionPhase; + logs: MCPConnectionLog[]; + } + | { + status: HealthCheckStatus.SUCCESS; + tools: MCPToolInfo[]; + serverInfo?: MCPServerInfo; + capabilities?: MCPCapabilitiesInfo; + transportType?: MCPTransportType; + protocolVersion?: string; + instructions?: string; + connectionTimeMs?: number; + logs: MCPConnectionLog[]; + }; + +/** + * Health check parameters + */ +export interface HealthCheckParams { + id: string; + enabled: boolean; + url: string; + requestTimeoutSeconds: number; + headers?: string; + useProxy?: boolean; +} + +export type MCPServerConfig = { + transport?: MCPTransportType; + url: string; + protocols?: string | string[]; + headers?: Record; + credentials?: RequestCredentials; + handshakeTimeoutMs?: number; + requestTimeoutMs?: number; + capabilities?: ClientCapabilities; + useProxy?: boolean; +}; + +export type MCPClientConfig = { + servers: Record; + protocolVersion?: string; + capabilities?: ClientCapabilities; + clientInfo?: Implementation; + requestTimeoutMs?: number; +}; + +export type MCPToolCallArguments = Record; + +export type MCPToolCall = { + id: string; + function: { + name: string; + arguments: string | MCPToolCallArguments; + }; +}; + +export type MCPServerSettingsEntry = { + id: string; + enabled: boolean; + url: string; + requestTimeoutSeconds: number; + headers?: string; + name?: string; + iconUrl?: string; + useProxy?: boolean; +}; + +export interface MCPHostManagerConfig { + servers: MCPClientConfig['servers']; + clientInfo?: Implementation; + capabilities?: ClientCapabilities; +} + +export interface OpenAIToolDefinition { + type: 'function'; + function: { + name: string; + description?: string; + parameters: Record; + }; +} + +export interface ServerStatus { + name: string; + isConnected: boolean; + toolCount: number; + error?: string; +} + +export interface MCPServerConnectionConfig { + name: string; + server: MCPServerConfig; + clientInfo?: Implementation; + capabilities?: ClientCapabilities; +} + +export interface ToolCallParams { + name: string; + arguments: Record; +} + +export interface ToolExecutionResult { + content: string; + isError: boolean; +} + +export interface ServerBuiltinToolInfo { + display_name: string; + tool: string; + type: ToolSource.BUILTIN; + permissions: { + write: boolean; + }; + definition: OpenAIToolDefinition; +} + +/** + * Progress tracking state for a specific operation + */ +export interface MCPProgressState { + progressToken: string | number; + serverName: string; + progress: number; + total?: number; + message?: string; + startTime: Date; + lastUpdate: Date; +} + +/** + * Resource annotations for audience and priority hints + */ +export interface MCPResourceAnnotations { + audience?: ('user' | 'assistant')[]; + priority?: number; + lastModified?: string; +} + +/** + * Icon definition for resources + */ +export interface MCPResourceIcon { + src: string; + mimeType?: MimeTypeUnion; + sizes?: string[]; + theme?: ColorMode.LIGHT | ColorMode.DARK; +} + +/** + * A known resource that the server is capable of reading + */ +export interface MCPResource { + uri: string; + name: string; + title?: string; + description?: string; + mimeType?: MimeTypeUnion; + annotations?: MCPResourceAnnotations; + icons?: MCPResourceIcon[]; + _meta?: Record; +} + +/** + * A template for dynamically generating resource URIs + */ +export interface MCPResourceTemplate { + uriTemplate: string; + name: string; + title?: string; + description?: string; + mimeType?: MimeTypeUnion; + annotations?: MCPResourceAnnotations; + icons?: MCPResourceIcon[]; + _meta?: Record; +} + +/** + * Text content from a resource + */ +export interface MCPTextResourceContent { + uri: string; + mimeType?: MimeTypeUnion; + text: string; +} + +/** + * Binary (blob) content from a resource + */ +export interface MCPBlobResourceContent { + uri: string; + mimeType?: MimeTypeUnion; + /** Base64-encoded binary data */ + blob: string; +} + +/** + * Union type for resource content + */ +export type MCPResourceContent = MCPTextResourceContent | MCPBlobResourceContent; + +/** + * Result from reading a resource + */ +export interface MCPReadResourceResult { + contents: MCPResourceContent[]; + _meta?: Record; +} + +/** + * Resource information for display in UI + */ +export interface MCPResourceInfo { + uri: string; + name: string; + title?: string; + description?: string; + mimeType?: MimeTypeUnion; + serverName: string; + annotations?: MCPResourceAnnotations; + icons?: MCPResourceIcon[]; +} + +/** + * Resource template information for display in UI + */ +export interface MCPResourceTemplateInfo { + uriTemplate: string; + name: string; + title?: string; + description?: string; + mimeType?: MimeTypeUnion; + serverName: string; + annotations?: MCPResourceAnnotations; + icons?: MCPResourceIcon[]; +} + +/** + * Cached resource content with metadata + */ +export interface MCPCachedResource { + resource: MCPResourceInfo; + content: MCPResourceContent[]; + fetchedAt: Date; + /** Whether this resource has an active subscription */ + subscribed?: boolean; +} + +/** + * Resource attachment for chat context + */ +export interface MCPResourceAttachment { + id: string; + resource: MCPResourceInfo; + content?: MCPResourceContent[]; + loading?: boolean; + error?: string; +} + +/** + * State for resource subscriptions + */ +export interface MCPResourceSubscription { + uri: string; + serverName: string; + subscribedAt: Date; + lastUpdate?: Date; +} + +/** + * Aggregated resources state per server + */ +export interface MCPServerResources { + serverName: string; + resources: MCPResource[]; + templates: MCPResourceTemplate[]; + lastFetched?: Date; + loading: boolean; + error?: string; +} diff --git a/tools/ui/src/lib/types/models.d.ts b/tools/ui/src/lib/types/models.d.ts new file mode 100644 index 000000000..b4d5f11f5 --- /dev/null +++ b/tools/ui/src/lib/types/models.d.ts @@ -0,0 +1,38 @@ +import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api'; + +export interface ModelModalities { + vision: boolean; + audio: boolean; +} + +export interface ModelOption { + id: string; + name: string; + model: string; + description?: string; + capabilities: string[]; + modalities?: ModelModalities; + details?: ApiModelDetails['details']; + meta?: ApiModelDataEntry['meta']; + parsedId?: ParsedModelId; + aliases?: string[]; + tags?: string[]; +} + +export interface ParsedModelId { + raw: string; + orgName: string | null; + modelName: string | null; + params: string | null; + activatedParams: string | null; + quantization: string | null; + tags: string[]; +} + +/** + * Modality capabilities for file validation + */ +export interface ModalityCapabilities { + hasVision: boolean; + hasAudio: boolean; +} diff --git a/tools/ui/src/lib/types/settings.d.ts b/tools/ui/src/lib/types/settings.d.ts new file mode 100644 index 000000000..1ab7a7e5d --- /dev/null +++ b/tools/ui/src/lib/types/settings.d.ts @@ -0,0 +1,151 @@ +import type { SETTING_CONFIG_DEFAULT, SETTINGS_SECTION_TITLES } from '$lib/constants'; +import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat'; +import type { OpenAIToolDefinition } from './mcp'; +import type { DatabaseMessageExtra } from './database'; +import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums'; +import type { Icon } from '@lucide/svelte'; +import type { Component } from 'svelte'; + +export type SettingsConfigValue = string | number | boolean | undefined; + +/** Section title type derived from registry section titles. */ +export type SettingsSectionTitle = + (typeof SETTINGS_SECTION_TITLES)[keyof typeof SETTINGS_SECTION_TITLES]; + +/** Per-setting metadata — one entry per setting. */ +export interface SettingsEntry { + key: string; + label: string; + help: string; + defaultValue: SettingsConfigValue; + type: SettingsFieldType; + section?: string; + options?: Array<{ value: string; label: string; icon: Component }>; + isExperimental?: boolean; + isPositiveInteger?: boolean; + sync?: { + serverKey: string; + paramType: SyncableParameterType; + }; +} + +/** A settings section with its icon, slug, title, and ordered settings. */ +export interface SettingsSectionEntry { + title: SettingsSectionTitle; + slug: string; + icon: Component; + settings: SettingsEntry[]; +} + +export interface SettingsFieldConfig { + key: string; + label: string; + type: SettingsFieldType; + isExperimental?: boolean; + help?: string; + options?: Array<{ value: string; label: string; icon?: typeof Icon }>; +} + +/** Re-exported for backward compatibility. */ +export interface SettingsSection { + fields?: SettingsFieldConfig[]; + icon: Component; + slug: string; + title: SettingsSectionTitle; +} + +export interface SettingsChatServiceOptions { + stream?: boolean; + // Model (required in ROUTER mode, optional in MODEL mode) + model?: string; + // System message to inject + systemMessage?: string; + // Disable reasoning parsing (use 'none' instead of 'auto') + disableReasoningParsing?: boolean; + // Strip reasoning content from context before sending + excludeReasoningFromContext?: boolean; + tools?: OpenAIToolDefinition[]; + // Generation parameters + temperature?: number; + max_tokens?: number; + // Sampling parameters + dynatemp_range?: number; + dynatemp_exponent?: number; + top_k?: number; + top_p?: number; + min_p?: number; + xtc_probability?: number; + xtc_threshold?: number; + typ_p?: number; + // Penalty parameters + repeat_last_n?: number; + repeat_penalty?: number; + presence_penalty?: number; + frequency_penalty?: number; + dry_multiplier?: number; + dry_base?: number; + dry_allowed_length?: number; + dry_penalty_last_n?: number; + // Sampler configuration + samplers?: string | string[]; + backend_sampling?: boolean; + // Custom parameters + custom?: string; + timings_per_token?: boolean; + // Continuation control (vLLM compat), opt in to the explicit continue final message flag + continueFinalMessage?: boolean; + // Callbacks + onChunk?: (chunk: string) => void; + onReasoningChunk?: (chunk: string) => void; + onToolCallChunk?: (chunk: string) => void; + onAttachments?: (extras: DatabaseMessageExtra[]) => void; + onModel?: (model: string) => void; + onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void; + onComplete?: ( + response: string, + reasoningContent?: string, + timings?: ChatMessageTimings, + toolCalls?: string + ) => void; + onError?: (error: Error) => void; +} + +export type SettingsConfigType = typeof SETTING_CONFIG_DEFAULT & { + [key: string]: SettingsConfigValue; +}; + +/** + * Parameter synchronization types for server defaults and user overrides + * Note: ParameterSource and SyncableParameterType enums are imported from '$lib/enums' + */ +export type ParameterValue = string | number | boolean; +export type ParameterRecord = Record; + +export interface ParameterInfo { + value: string | number | boolean; + source: ParameterSource; + serverDefault?: string | number | boolean; + userOverride?: string | number | boolean; +} + +export interface SyncableParameter { + key: string; + serverKey: string; + type: SyncableParameterType; + canSync: boolean; +} + +/** + * Shape of the settings JSON export file. + * Versioned to allow future schema evolution. + */ +export interface SettingsExportType { + /** Export format version — bumped on breaking changes */ + version: number; + /** Unix timestamp of export */ + timestamp: number; + /** Full settings config (includes theme as a config key) */ + config: SettingsConfigType; + /** Keys that differ from server defaults (derived, but persisted for fidelity) */ + userOverrides: string[]; +} diff --git a/tools/ui/src/lib/types/tools.d.ts b/tools/ui/src/lib/types/tools.d.ts new file mode 100644 index 000000000..a17a0c9a9 --- /dev/null +++ b/tools/ui/src/lib/types/tools.d.ts @@ -0,0 +1,19 @@ +import type { ToolSource } from '$lib/enums'; +import type { OpenAIToolDefinition } from './mcp'; + +export interface ToolEntry { + source: ToolSource; + /** For MCP tools, the server display name (used for UI grouping) */ + serverName?: string; + /** For MCP tools, the server ID (used for permission keys) */ + serverId?: string; + definition: OpenAIToolDefinition; +} + +export interface ToolGroup { + source: ToolSource; + label: string; + /** For MCP groups, the server ID */ + serverId?: string; + tools: OpenAIToolDefinition[]; +} diff --git a/tools/ui/src/lib/utils/abort.ts b/tools/ui/src/lib/utils/abort.ts new file mode 100644 index 000000000..fc4f31ec6 --- /dev/null +++ b/tools/ui/src/lib/utils/abort.ts @@ -0,0 +1,151 @@ +/** + * Abort Signal Utilities + * + * Provides utilities for consistent AbortSignal propagation across the application. + * These utilities help ensure that async operations can be properly cancelled + * when needed (e.g., user stops generation, navigates away, etc.). + */ + +/** + * Throws an AbortError if the signal is aborted. + * Use this at the start of async operations to fail fast. + * + * @param signal - Optional AbortSignal to check + * @throws DOMException with name 'AbortError' if signal is aborted + * + * @example + * ```ts + * async function fetchData(signal?: AbortSignal) { + * throwIfAborted(signal); + * // ... proceed with operation + * } + * ``` + */ +export function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new DOMException('Operation was aborted', 'AbortError'); + } +} + +/** + * Checks if an error is an AbortError. + * Use this to distinguish between user-initiated cancellation and actual errors. + * + * @param error - Error to check + * @returns true if the error is an AbortError + * + * @example + * ```ts + * try { + * await fetchData(signal); + * } catch (error) { + * if (isAbortError(error)) { + * // User cancelled - no error dialog needed + * return; + * } + * // Handle actual error + * } + * ``` + */ +export function isAbortError(error: unknown): boolean { + if (error instanceof DOMException && error.name === 'AbortError') { + return true; + } + if (error instanceof Error && error.name === 'AbortError') { + return true; + } + return false; +} + +/** + * Creates a new AbortController that is linked to one or more parent signals. + * When any parent signal aborts, the returned controller also aborts. + * + * Useful for creating child operations that should be cancelled when + * either the parent operation or their own timeout/condition triggers. + * + * @param signals - Parent signals to link to (undefined signals are ignored) + * @returns A new AbortController linked to all provided signals + * + * @example + * ```ts + * // Link to user's abort signal and add a timeout + * const linked = createLinkedController(userSignal, timeoutSignal); + * await fetch(url, { signal: linked.signal }); + * ``` + */ +export function createLinkedController(...signals: (AbortSignal | undefined)[]): AbortController { + const controller = new AbortController(); + + for (const signal of signals) { + if (!signal) continue; + + // If already aborted, abort immediately + if (signal.aborted) { + controller.abort(signal.reason); + return controller; + } + + // Link to parent signal + signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true }); + } + + return controller; +} + +/** + * Creates an AbortSignal that times out after the specified duration. + * + * @param ms - Timeout duration in milliseconds + * @returns AbortSignal that will abort after the timeout + * + * @example + * ```ts + * const signal = createTimeoutSignal(5000); // 5 second timeout + * await fetch(url, { signal }); + * ``` + */ +export function createTimeoutSignal(ms: number): AbortSignal { + return AbortSignal.timeout(ms); +} + +/** + * Wraps a promise to reject if the signal is aborted. + * Useful for making non-abortable promises respect an AbortSignal. + * + * @param promise - Promise to wrap + * @param signal - AbortSignal to respect + * @returns Promise that rejects with AbortError if signal aborts + * + * @example + * ```ts + * // Make a non-abortable operation respect abort signal + * const result = await withAbortSignal( + * someNonAbortableOperation(), + * signal + * ); + * ``` + */ +export async function withAbortSignal(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + + throwIfAborted(signal); + + return new Promise((resolve, reject) => { + const abortHandler = () => { + reject(new DOMException('Operation was aborted', 'AbortError')); + }; + + signal.addEventListener('abort', abortHandler, { once: true }); + + promise + .then((value) => { + signal.removeEventListener('abort', abortHandler); + resolve(value); + }) + .catch((error) => { + signal.removeEventListener('abort', abortHandler); + reject(error); + }); + }); +} diff --git a/tools/ui/src/lib/utils/agentic.ts b/tools/ui/src/lib/utils/agentic.ts new file mode 100644 index 000000000..549a1c9a0 --- /dev/null +++ b/tools/ui/src/lib/utils/agentic.ts @@ -0,0 +1,227 @@ +import { AgenticSectionType, MessageRole } from '$lib/enums'; +import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants'; +import type { ApiChatCompletionToolCall } from '$lib/types/api'; +import type { + DatabaseMessage, + DatabaseMessageExtra, + DatabaseMessageExtraImageFile +} from '$lib/types/database'; +import { AttachmentType } from '$lib/enums'; + +/** + * Represents a parsed section of agentic content for display + */ +export interface AgenticSection { + type: AgenticSectionType; + content: string; + toolName?: string; + toolArgs?: string; + toolResult?: string; + toolResultExtras?: DatabaseMessageExtra[]; +} + +/** + * Represents a tool result line that may reference an image attachment + */ +export type ToolResultLine = { + text: string; + image?: DatabaseMessageExtraImageFile; +}; + +/** + * Derives display sections from a single assistant message and its direct tool results. + * + * @param message - The assistant message + * @param toolMessages - Tool result messages for this assistant's tool_calls + * @param streamingToolCalls - Partial tool calls during streaming (not yet persisted) + */ +function deriveSingleTurnSections( + message: DatabaseMessage, + toolMessages: DatabaseMessage[] = [], + streamingToolCalls: ApiChatCompletionToolCall[] = [], + isStreaming: boolean = false +): AgenticSection[] { + const sections: AgenticSection[] = []; + + // 1. Reasoning content (from dedicated field) + if (message.reasoningContent) { + const toolCalls = parseToolCalls(message.toolCalls); + const hasContentAfterReasoning = + !!message.content?.trim() || toolCalls.length > 0 || streamingToolCalls.length > 0; + const isPending = isStreaming && !hasContentAfterReasoning; + sections.push({ + type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING, + content: message.reasoningContent + }); + } + + // 2. Text content + if (message.content?.trim()) { + sections.push({ + type: AgenticSectionType.TEXT, + content: message.content + }); + } + + // 3. Persisted tool calls (from message.toolCalls field) + const toolCalls = parseToolCalls(message.toolCalls); + for (const tc of toolCalls) { + const resultMsg = toolMessages.find((m) => m.toolCallId === tc.id); + // Only show as pending/loading if we're actively streaming; otherwise it's just a tool call without result + const type = resultMsg + ? AgenticSectionType.TOOL_CALL + : isStreaming + ? AgenticSectionType.TOOL_CALL_PENDING + : AgenticSectionType.TOOL_CALL; + sections.push({ + type, + content: resultMsg?.content || '', + toolName: tc.function?.name, + toolArgs: tc.function?.arguments, + toolResult: resultMsg?.content, + toolResultExtras: resultMsg?.extra + }); + } + + // 4. Streaming tool calls (not yet persisted - currently being received) + for (const tc of streamingToolCalls) { + // Skip if already in persisted tool calls + if (tc.id && toolCalls.find((t) => t.id === tc.id)) continue; + sections.push({ + type: AgenticSectionType.TOOL_CALL_STREAMING, + content: '', + toolName: tc.function?.name, + toolArgs: tc.function?.arguments + }); + } + + return sections; +} + +/** + * Derives display sections from structured message data. + * + * Handles both single-turn (one assistant + its tool results) and multi-turn + * agentic sessions (multiple assistant + tool messages grouped together). + * + * When `toolMessages` contains continuation assistant messages (from multi-turn + * agentic flows), they are processed in order to produce sections across all turns. + * + * @param message - The first/anchor assistant message + * @param toolMessages - Tool result messages and continuation assistant messages + * @param streamingToolCalls - Partial tool calls during streaming (not yet persisted) + * @param isStreaming - Whether the message is currently being streamed + */ +export function deriveAgenticSections( + message: DatabaseMessage, + toolMessages: DatabaseMessage[] = [], + streamingToolCalls: ApiChatCompletionToolCall[] = [], + isStreaming: boolean = false +): AgenticSection[] { + const hasAssistantContinuations = toolMessages.some((m) => m.role === MessageRole.ASSISTANT); + + if (!hasAssistantContinuations) { + return deriveSingleTurnSections(message, toolMessages, streamingToolCalls, isStreaming); + } + + const sections: AgenticSection[] = []; + + const firstTurnToolMsgs = collectToolMessages(toolMessages, 0); + sections.push(...deriveSingleTurnSections(message, firstTurnToolMsgs)); + + let i = firstTurnToolMsgs.length; + + while (i < toolMessages.length) { + const msg = toolMessages[i]; + + if (msg.role === MessageRole.ASSISTANT) { + const turnToolMsgs = collectToolMessages(toolMessages, i + 1); + const isLastTurn = i + 1 + turnToolMsgs.length >= toolMessages.length; + + sections.push( + ...deriveSingleTurnSections( + msg, + turnToolMsgs, + isLastTurn ? streamingToolCalls : [], + isLastTurn && isStreaming + ) + ); + + i += 1 + turnToolMsgs.length; + } else { + i++; + } + } + + return sections; +} + +/** + * Collect consecutive tool messages starting at `startIndex`. + */ +function collectToolMessages(messages: DatabaseMessage[], startIndex: number): DatabaseMessage[] { + const result: DatabaseMessage[] = []; + + for (let i = startIndex; i < messages.length; i++) { + if (messages[i].role === MessageRole.TOOL) { + result.push(messages[i]); + } else { + break; + } + } + + return result; +} + +/** + * Parse tool result text into lines, matching image attachments by name. + */ +export function parseToolResultWithImages( + toolResult: string, + extras?: DatabaseMessageExtra[] +): ToolResultLine[] { + const lines = toolResult.split(NEWLINE_SEPARATOR); + return lines.map((line) => { + const match = line.match(ATTACHMENT_SAVED_REGEX); + if (!match || !extras) return { text: line }; + + const attachmentName = match[1]; + const image = extras.find( + (e): e is DatabaseMessageExtraImageFile => + e.type === AttachmentType.IMAGE && e.name === attachmentName + ); + + return { text: line, image }; + }); +} + +/** + * Safely parse the toolCalls JSON string from a DatabaseMessage. + */ +function parseToolCalls(toolCallsJson?: string): ApiChatCompletionToolCall[] { + if (!toolCallsJson) return []; + + try { + const parsed = JSON.parse(toolCallsJson); + + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * Check if a message has agentic content (tool calls or is part of an agentic flow). + */ +export function hasAgenticContent( + message: DatabaseMessage, + toolMessages: DatabaseMessage[] = [] +): boolean { + if (message.toolCalls) { + const tc = parseToolCalls(message.toolCalls); + + if (tc.length > 0) return true; + } + + return toolMessages.length > 0; +} diff --git a/tools/ui/src/lib/utils/api-fetch.ts b/tools/ui/src/lib/utils/api-fetch.ts new file mode 100644 index 000000000..80781b98e --- /dev/null +++ b/tools/ui/src/lib/utils/api-fetch.ts @@ -0,0 +1,158 @@ +import { base } from '$app/paths'; +import { getJsonHeaders, getAuthHeaders } from './api-headers'; +import { UrlProtocol } from '$lib/enums'; + +/** + * API Fetch Utilities + * + * Provides common fetch patterns used across services: + * - Automatic JSON headers + * - Error handling with proper error messages + * - Base path resolution + */ + +export interface ApiFetchOptions extends Omit { + /** + * Use auth-only headers (no Content-Type). + * Default: false (uses JSON headers with Content-Type: application/json) + */ + authOnly?: boolean; + /** + * Additional headers to merge with default headers. + */ + headers?: Record; +} + +/** + * Fetch JSON data from an API endpoint with standard headers and error handling. + * + * @param path - API path (will be prefixed with base path) + * @param options - Fetch options with additional authOnly flag + * @returns Parsed JSON response + * @throws Error with formatted message on failure + * + * @example + * ```typescript + * // GET request + * const models = await apiFetch('/v1/models'); + * + * // POST request + * const result = await apiFetch('/models/load', { + * method: 'POST', + * body: JSON.stringify({ model: 'gpt-4' }) + * }); + * ``` + */ +export async function apiFetch(path: string, options: ApiFetchOptions = {}): Promise { + const { authOnly = false, headers: customHeaders, ...fetchOptions } = options; + + const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders(); + const headers = { ...baseHeaders, ...customHeaders }; + + const url = + path.startsWith(UrlProtocol.HTTP) || path.startsWith(UrlProtocol.HTTPS) + ? path + : `${base}${path}`; + + const response = await fetch(url, { + ...fetchOptions, + headers + }); + + if (!response.ok) { + const errorMessage = await parseErrorMessage(response); + throw new Error(errorMessage); + } + + return response.json() as Promise; +} + +/** + * Fetch with URL constructed from base URL and query parameters. + * + * @param basePath - Base API path + * @param params - Query parameters to append + * @param options - Fetch options + * @returns Parsed JSON response + * + * @example + * ```typescript + * const props = await apiFetchWithParams('./props', { + * model: 'gpt-4', + * autoload: 'false' + * }); + * ``` + */ +export async function apiFetchWithParams( + basePath: string, + params: Record, + options: ApiFetchOptions = {} +): Promise { + const url = new URL(basePath, window.location.href); + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, value); + } + } + + const { authOnly = false, headers: customHeaders, ...fetchOptions } = options; + + const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders(); + const headers = { ...baseHeaders, ...customHeaders }; + + const response = await fetch(url.toString(), { + ...fetchOptions, + headers + }); + + if (!response.ok) { + const errorMessage = await parseErrorMessage(response); + throw new Error(errorMessage); + } + + return response.json() as Promise; +} + +/** + * POST JSON data to an API endpoint. + * + * @param path - API path + * @param body - Request body (will be JSON stringified) + * @param options - Additional fetch options + * @returns Parsed JSON response + */ +export async function apiPost( + path: string, + body: B, + options: ApiFetchOptions = {} +): Promise { + return apiFetch(path, { + method: 'POST', + body: JSON.stringify(body), + ...options + }); +} + +/** + * Parse error message from a failed response. + * Tries to extract error message from JSON body, falls back to status text. + */ +async function parseErrorMessage(response: Response): Promise { + try { + const errorData = await response.json(); + if (errorData?.error?.message) { + return errorData.error.message; + } + if (errorData?.error && typeof errorData.error === 'string') { + return errorData.error; + } + if (errorData?.message) { + return errorData.message; + } + } catch { + // JSON parsing failed, use status text + } + + return `Request failed: ${response.status} ${response.statusText}`; +} diff --git a/tools/ui/src/lib/utils/api-headers.ts b/tools/ui/src/lib/utils/api-headers.ts new file mode 100644 index 000000000..c0a5309b9 --- /dev/null +++ b/tools/ui/src/lib/utils/api-headers.ts @@ -0,0 +1,67 @@ +import { config } from '$lib/stores/settings.svelte'; +import { REDACTED_HEADERS } from '$lib/constants'; +import { redactValue } from './redact'; + +/** + * Get authorization headers for API requests + * Includes Bearer token if API key is configured + */ +export function getAuthHeaders(): Record { + const currentConfig = config(); + const apiKey = currentConfig.apiKey?.toString().trim(); + + return apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; +} + +/** + * Get standard JSON headers with optional authorization + */ +export function getJsonHeaders(): Record { + return { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }; +} + +/** + * Sanitize HTTP headers by redacting sensitive values. + * Known sensitive headers (from REDACTED_HEADERS) and any extra headers + * specified by the caller are fully redacted. Headers listed in + * `partialRedactHeaders` are partially redacted, showing only the + * specified number of trailing characters. + * + * @param headers - Headers to sanitize + * @param extraRedactedHeaders - Additional header names to fully redact + * @param partialRedactHeaders - Map of header name -> number of trailing chars to keep visible + * @returns Object with header names as keys and (possibly redacted) values + */ +export function sanitizeHeaders( + headers?: HeadersInit, + extraRedactedHeaders?: Iterable, + partialRedactHeaders?: Map +): Record { + if (!headers) { + return {}; + } + + const normalized = new Headers(headers); + const sanitized: Record = {}; + const redactedHeaders = new Set( + Array.from(extraRedactedHeaders ?? [], (header) => header.toLowerCase()) + ); + + for (const [key, value] of normalized.entries()) { + const normalizedKey = key.toLowerCase(); + const partialChars = partialRedactHeaders?.get(normalizedKey); + + if (partialChars !== undefined) { + sanitized[key] = redactValue(value, partialChars); + } else if (REDACTED_HEADERS.has(normalizedKey) || redactedHeaders.has(normalizedKey)) { + sanitized[key] = redactValue(value); + } else { + sanitized[key] = value; + } + } + + return sanitized; +} diff --git a/tools/ui/src/lib/utils/api-key-validation.ts b/tools/ui/src/lib/utils/api-key-validation.ts new file mode 100644 index 000000000..948b7d7b6 --- /dev/null +++ b/tools/ui/src/lib/utils/api-key-validation.ts @@ -0,0 +1,45 @@ +import { base } from '$app/paths'; +import { error } from '@sveltejs/kit'; +import { browser } from '$app/environment'; +import { config } from '$lib/stores/settings.svelte'; + +/** + * Validates API key by making a request to the server props endpoint + * Throws SvelteKit errors for authentication failures or server issues + */ +export async function validateApiKey(fetch: typeof globalThis.fetch): Promise { + if (!browser) { + return; + } + + try { + const apiKey = config().apiKey; + + const headers: Record = { + 'Content-Type': 'application/json' + }; + + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + const response = await fetch(`${base}/props`, { headers }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + throw error(401, 'Access denied'); + } + + console.warn(`Server responded with status ${response.status} during API key validation`); + return; + } + } catch (err) { + // If it's already a SvelteKit error, re-throw it + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + + // Network or other errors + console.warn('Cannot connect to server for API key validation:', err); + } +} diff --git a/tools/ui/src/lib/utils/attachment-display.ts b/tools/ui/src/lib/utils/attachment-display.ts new file mode 100644 index 000000000..30c7043bf --- /dev/null +++ b/tools/ui/src/lib/utils/attachment-display.ts @@ -0,0 +1,85 @@ +import { AttachmentType, FileTypeCategory, SpecialFileType } from '$lib/enums'; +import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils'; +import type { + AttachmentDisplayItemsOptions, + ChatAttachmentDisplayItem, + ChatUploadedFile +} from '$lib/types'; + +/** + * Check if a display item represents an MCP prompt + * (either from attachment type or uploaded file with mcpPrompt metadata) + */ +export function isMcpPrompt(item: ChatAttachmentDisplayItem): boolean { + if (item.attachment?.type === AttachmentType.MCP_PROMPT) { + return true; + } + if (item.uploadedFile?.type === SpecialFileType.MCP_PROMPT && item.uploadedFile.mcpPrompt) { + return true; + } + return false; +} + +/** + * Check if a display item represents an MCP resource + */ +export function isMcpResource(item: ChatAttachmentDisplayItem): boolean { + return item.attachment?.type === AttachmentType.MCP_RESOURCE; +} + +/** + * Gets the file type category from an uploaded file, checking both MIME type and extension + */ +function getUploadedFileCategory(file: ChatUploadedFile): FileTypeCategory | null { + const categoryByMime = getFileTypeCategory(file.type); + + if (categoryByMime) { + return categoryByMime; + } + + return getFileTypeCategoryByExtension(file.name); +} + +/** + * Creates a unified list of display items from uploaded files and stored attachments. + * Items are returned in reverse order (newest first). + */ +export function getAttachmentDisplayItems( + options: AttachmentDisplayItemsOptions +): ChatAttachmentDisplayItem[] { + const { uploadedFiles = [], attachments = [] } = options; + const items: ChatAttachmentDisplayItem[] = []; + + // Add uploaded files (ChatForm) + for (const file of uploadedFiles) { + items.push({ + id: file.id, + name: file.name, + size: file.size, + preview: file.preview, + isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE, + isLoading: file.isLoading, + loadError: file.loadError, + uploadedFile: file, + textContent: file.textContent + }); + } + + // Add stored attachments (ChatMessage) + for (const [index, attachment] of attachments.entries()) { + const isImage = isImageFile(attachment); + + items.push({ + id: `attachment-${index}`, + name: attachment.name, + size: 'size' in attachment ? attachment.size : undefined, + preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined, + isImage, + attachment, + attachmentIndex: index, + textContent: 'content' in attachment ? attachment.content : undefined + }); + } + + return items.reverse(); +} diff --git a/tools/ui/src/lib/utils/attachment-type.ts b/tools/ui/src/lib/utils/attachment-type.ts new file mode 100644 index 000000000..9e9f09601 --- /dev/null +++ b/tools/ui/src/lib/utils/attachment-type.ts @@ -0,0 +1,105 @@ +import { AttachmentType, FileTypeCategory } from '$lib/enums'; +import { getFileTypeCategory, getFileTypeCategoryByExtension } from '$lib/utils'; + +/** + * Gets the file type category from an uploaded file, checking both MIME type and extension + * @param uploadedFile - The uploaded file to check + * @returns The file type category or null if not recognized + */ +function getUploadedFileCategory(uploadedFile: ChatUploadedFile): FileTypeCategory | null { + // First try MIME type + const categoryByMime = getFileTypeCategory(uploadedFile.type); + + if (categoryByMime) { + return categoryByMime; + } + + // Fallback to extension (browsers don't always provide correct MIME types) + return getFileTypeCategoryByExtension(uploadedFile.name); +} + +/** + * Determines if an attachment or uploaded file is a text file + * @param uploadedFile - Optional uploaded file + * @param attachment - Optional database attachment + * @returns true if the file is a text file + */ +export function isTextFile( + attachment?: DatabaseMessageExtra, + uploadedFile?: ChatUploadedFile +): boolean { + if (uploadedFile) { + return getUploadedFileCategory(uploadedFile) === FileTypeCategory.TEXT; + } + + if (attachment) { + return ( + attachment.type === AttachmentType.TEXT || attachment.type === AttachmentType.LEGACY_CONTEXT + ); + } + + return false; +} + +/** + * Determines if an attachment or uploaded file is an image + * @param uploadedFile - Optional uploaded file + * @param attachment - Optional database attachment + * @returns true if the file is an image + */ +export function isImageFile( + attachment?: DatabaseMessageExtra, + uploadedFile?: ChatUploadedFile +): boolean { + if (uploadedFile) { + return getUploadedFileCategory(uploadedFile) === FileTypeCategory.IMAGE; + } + + if (attachment) { + return attachment.type === AttachmentType.IMAGE; + } + + return false; +} + +/** + * Determines if an attachment or uploaded file is a PDF + * @param uploadedFile - Optional uploaded file + * @param attachment - Optional database attachment + * @returns true if the file is a PDF + */ +export function isPdfFile( + attachment?: DatabaseMessageExtra, + uploadedFile?: ChatUploadedFile +): boolean { + if (uploadedFile) { + return getUploadedFileCategory(uploadedFile) === FileTypeCategory.PDF; + } + + if (attachment) { + return attachment.type === AttachmentType.PDF; + } + + return false; +} + +/** + * Determines if an attachment or uploaded file is an audio file + * @param uploadedFile - Optional uploaded file + * @param attachment - Optional database attachment + * @returns true if the file is an audio file + */ +export function isAudioFile( + attachment?: DatabaseMessageExtra, + uploadedFile?: ChatUploadedFile +): boolean { + if (uploadedFile) { + return getUploadedFileCategory(uploadedFile) === FileTypeCategory.AUDIO; + } + + if (attachment) { + return attachment.type === AttachmentType.AUDIO; + } + + return false; +} diff --git a/tools/ui/src/lib/utils/audio-recording.ts b/tools/ui/src/lib/utils/audio-recording.ts new file mode 100644 index 000000000..ab207b7a4 --- /dev/null +++ b/tools/ui/src/lib/utils/audio-recording.ts @@ -0,0 +1,257 @@ +import { MimeTypeAudio } from '$lib/enums'; + +/** + * AudioRecorder - Browser-based audio recording with MediaRecorder API + * + * This class provides a complete audio recording solution using the browser's MediaRecorder API. + * It handles microphone access, recording state management, and audio format optimization. + * + * **Features:** + * - Automatic microphone permission handling + * - Audio enhancement (echo cancellation, noise suppression, auto gain) + * - Multiple format support with fallback (WAV, WebM, MP4, AAC) + * - Real-time recording state tracking + * - Proper cleanup and resource management + */ +export class AudioRecorder { + private mediaRecorder: MediaRecorder | null = null; + private audioChunks: Blob[] = []; + private stream: MediaStream | null = null; + private recordingState: boolean = false; + + async startRecording(): Promise { + try { + this.stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + } + }); + + this.initializeRecorder(this.stream); + + this.audioChunks = []; + // Start recording with a small timeslice to ensure we get data + this.mediaRecorder!.start(100); + this.recordingState = true; + } catch (error) { + console.error('Failed to start recording:', error); + throw new Error('Failed to access microphone. Please check permissions.'); + } + } + + async stopRecording(): Promise { + return new Promise((resolve, reject) => { + const recorder = this.mediaRecorder; + const chunks = this.audioChunks; + const stream = this.stream; + + if (!recorder || recorder.state === 'inactive') { + reject(new Error('No active recording to stop')); + return; + } + + // Detach instance state right away so a new startRecording can take over without race + this.mediaRecorder = null; + this.audioChunks = []; + this.stream = null; + this.recordingState = false; + + recorder.onstop = () => { + const audioBlob = new Blob(chunks, { + type: recorder.mimeType || MimeTypeAudio.WAV + }); + + if (stream) { + for (const track of stream.getTracks()) { + track.stop(); + } + } + + resolve(audioBlob); + }; + + recorder.onerror = (event) => { + console.error('Recording error:', event); + + if (stream) { + for (const track of stream.getTracks()) { + track.stop(); + } + } + + reject(new Error('Recording failed')); + }; + + recorder.stop(); + }); + } + + isRecording(): boolean { + return this.recordingState; + } + + cancelRecording(): void { + const recorder = this.mediaRecorder; + const stream = this.stream; + + this.mediaRecorder = null; + this.audioChunks = []; + this.stream = null; + this.recordingState = false; + + if (recorder && recorder.state !== 'inactive') { + // Drop the original handlers so the pending stop event does not touch the instance + recorder.onstop = null; + recorder.onerror = null; + recorder.stop(); + } + + if (stream) { + for (const track of stream.getTracks()) { + track.stop(); + } + } + } + + private initializeRecorder(stream: MediaStream): void { + const options: MediaRecorderOptions = {}; + + if (MediaRecorder.isTypeSupported(MimeTypeAudio.WAV)) { + options.mimeType = MimeTypeAudio.WAV; + } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM_OPUS)) { + options.mimeType = MimeTypeAudio.WEBM_OPUS; + } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.WEBM)) { + options.mimeType = MimeTypeAudio.WEBM; + } else if (MediaRecorder.isTypeSupported(MimeTypeAudio.MP4)) { + options.mimeType = MimeTypeAudio.MP4; + } else { + console.warn('No preferred audio format supported, using default'); + } + + this.mediaRecorder = new MediaRecorder(stream, options); + + this.mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + this.audioChunks.push(event.data); + } + }; + + this.mediaRecorder.onstop = () => { + this.recordingState = false; + }; + + this.mediaRecorder.onerror = (event) => { + console.error('MediaRecorder error:', event); + this.recordingState = false; + }; + } +} + +export async function convertToWav(audioBlob: Blob): Promise { + try { + if (audioBlob.type.includes('wav')) { + return audioBlob; + } + + const arrayBuffer = await audioBlob.arrayBuffer(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + + try { + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + return audioBufferToWav(audioBuffer); + } finally { + audioContext.close(); + } + } catch (error) { + console.error('Failed to convert audio to WAV:', error); + return audioBlob; + } +} + +function audioBufferToWav(buffer: AudioBuffer): Blob { + const length = buffer.length; + const numberOfChannels = buffer.numberOfChannels; + const sampleRate = buffer.sampleRate; + const bytesPerSample = 2; // 16-bit + const blockAlign = numberOfChannels * bytesPerSample; + const byteRate = sampleRate * blockAlign; + const dataSize = length * blockAlign; + const bufferSize = 44 + dataSize; + + const arrayBuffer = new ArrayBuffer(bufferSize); + const view = new DataView(arrayBuffer); + + const writeString = (offset: number, string: string) => { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + }; + + writeString(0, 'RIFF'); // ChunkID + view.setUint32(4, bufferSize - 8, true); // ChunkSize + writeString(8, 'WAVE'); // Format + writeString(12, 'fmt '); // Subchunk1ID + view.setUint32(16, 16, true); // Subchunk1Size + view.setUint16(20, 1, true); // AudioFormat (PCM) + view.setUint16(22, numberOfChannels, true); // NumChannels + view.setUint32(24, sampleRate, true); // SampleRate + view.setUint32(28, byteRate, true); // ByteRate + view.setUint16(32, blockAlign, true); // BlockAlign + view.setUint16(34, 16, true); // BitsPerSample + writeString(36, 'data'); // Subchunk2ID + view.setUint32(40, dataSize, true); // Subchunk2Size + + // Cache channel arrays, write PCM via Int16Array (native little-endian, matches WAV) + const channels: Float32Array[] = new Array(numberOfChannels); + for (let c = 0; c < numberOfChannels; c++) { + channels[c] = buffer.getChannelData(c); + } + + const pcm = new Int16Array(arrayBuffer, 44, length * numberOfChannels); + let p = 0; + for (let i = 0; i < length; i++) { + for (let c = 0; c < numberOfChannels; c++) { + let s = channels[c][i]; + if (s > 1) s = 1; + else if (s < -1) s = -1; + pcm[p++] = s * 0x7fff; + } + } + + return new Blob([arrayBuffer], { type: MimeTypeAudio.WAV }); +} + +/** + * Create a File object from audio blob with timestamp-based naming + * @param audioBlob - The audio blob to wrap + * @param filename - Optional custom filename + * @returns File object with appropriate name and metadata + */ +export function createAudioFile(audioBlob: Blob, filename?: string): File { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const extension = audioBlob.type.includes('wav') ? 'wav' : 'mp3'; + const defaultFilename = `recording-${timestamp}.${extension}`; + + return new File([audioBlob], filename || defaultFilename, { + type: audioBlob.type, + lastModified: Date.now() + }); +} + +/** + * Check if audio recording is supported in the current browser + * @returns True if MediaRecorder and getUserMedia are available + */ +export function isAudioRecordingSupported(): boolean { + return !!( + typeof navigator !== 'undefined' && + navigator.mediaDevices && + typeof navigator.mediaDevices.getUserMedia === 'function' && + typeof window !== 'undefined' && + window.MediaRecorder + ); +} diff --git a/tools/ui/src/lib/utils/autoresize-textarea.ts b/tools/ui/src/lib/utils/autoresize-textarea.ts new file mode 100644 index 000000000..cfee5ec15 --- /dev/null +++ b/tools/ui/src/lib/utils/autoresize-textarea.ts @@ -0,0 +1,10 @@ +/** + * Automatically resizes a textarea element to fit its content + * @param textareaElement - The textarea element to resize + */ +export default function autoResizeTextarea(textareaElement: HTMLTextAreaElement | null): void { + if (textareaElement) { + textareaElement.style.height = '1rem'; + textareaElement.style.height = textareaElement.scrollHeight + 'px'; + } +} diff --git a/tools/ui/src/lib/utils/branching.ts b/tools/ui/src/lib/utils/branching.ts new file mode 100644 index 000000000..4e117b3c2 --- /dev/null +++ b/tools/ui/src/lib/utils/branching.ts @@ -0,0 +1,301 @@ +/** + * Message branching utilities for conversation tree navigation. + * + * Conversation branching allows users to edit messages and create alternate paths + * while preserving the original conversation flow. Each message has parent/children + * relationships forming a tree structure. + * + * Example tree: + * root + * ├── message 1 (user) + * │ └── message 2 (assistant) + * │ ├── message 3 (user) + * │ └── message 6 (user) ← new branch + * └── message 4 (user) + * └── message 5 (assistant) + */ + +import { MessageRole } from '$lib/enums'; + +/** + * Finds a message by its ID in the given messages array. + */ +export function findMessageById( + messages: readonly DatabaseMessage[], + id: string | null | undefined +): DatabaseMessage | undefined { + if (!id) return undefined; + return messages.find((m) => m.id === id); +} + +/** + * Filters messages to get the conversation path from root to a specific leaf node. + * If the leafNodeId doesn't exist, returns the path with the latest timestamp. + * + * @param messages - All messages in the conversation + * @param leafNodeId - The target leaf node ID to trace back from + * @param includeRoot - Whether to include root messages in the result + * @returns Array of messages from root to leaf, sorted by timestamp + */ +export function filterByLeafNodeId( + messages: readonly DatabaseMessage[], + leafNodeId: string, + includeRoot: boolean = false +): readonly DatabaseMessage[] { + const result: DatabaseMessage[] = []; + const nodeMap = new Map(); + + // Build node map for quick lookups + for (const msg of messages) { + nodeMap.set(msg.id, msg); + } + + // Find the starting node (leaf node or latest if not found) + let startNode: DatabaseMessage | undefined = nodeMap.get(leafNodeId); + if (!startNode) { + // If leaf node not found, use the message with latest timestamp + let latestTime = -1; + for (const msg of messages) { + if (msg.timestamp > latestTime) { + startNode = msg; + latestTime = msg.timestamp; + } + } + } + + // Traverse from leaf to root, collecting messages + let currentNode: DatabaseMessage | undefined = startNode; + while (currentNode) { + // Include message if it's not root, or if we want to include root + if (currentNode.type !== 'root' || includeRoot) { + result.push(currentNode); + } + + // Stop traversal if parent is null (reached root) + if (currentNode.parent === null) { + break; + } + currentNode = nodeMap.get(currentNode.parent); + } + + // Sort: system messages first, then by timestamp + result.sort((a, b) => { + if (a.role === MessageRole.SYSTEM && b.role !== MessageRole.SYSTEM) return -1; + if (a.role !== MessageRole.SYSTEM && b.role === MessageRole.SYSTEM) return 1; + + return a.timestamp - b.timestamp; + }); + return result; +} + +/** + * Finds the leaf node (message with no children) for a given message branch. + * Traverses down the tree following the last child until reaching a leaf. + * + * @param messages - All messages in the conversation + * @param messageId - Starting message ID to find leaf for + * @returns The leaf node ID, or the original messageId if no children + */ +export function findLeafNode(messages: readonly DatabaseMessage[], messageId: string): string { + const nodeMap = new Map(); + + // Build node map for quick lookups + for (const msg of messages) { + nodeMap.set(msg.id, msg); + } + + let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId); + while (currentNode && currentNode.children.length > 0) { + // Follow the last child (most recent branch) + const lastChildId = currentNode.children[currentNode.children.length - 1]; + currentNode = nodeMap.get(lastChildId); + } + + return currentNode?.id ?? messageId; +} + +/** + * Finds all descendant messages (children, grandchildren, etc.) of a given message. + * This is used for cascading deletion to remove all messages in a branch. + * + * @param messages - All messages in the conversation + * @param messageId - The root message ID to find descendants for + * @returns Array of all descendant message IDs + */ +export function findDescendantMessages( + messages: readonly DatabaseMessage[], + messageId: string +): string[] { + const nodeMap = new Map(); + + // Build node map for quick lookups + for (const msg of messages) { + nodeMap.set(msg.id, msg); + } + + const descendants: string[] = []; + const queue: string[] = [messageId]; + + while (queue.length > 0) { + const currentId = queue.shift()!; + const currentNode = nodeMap.get(currentId); + + if (currentNode) { + // Add all children to the queue and descendants list + for (const childId of currentNode.children) { + descendants.push(childId); + queue.push(childId); + } + } + } + + return descendants; +} + +/** + * Gets sibling information for a message, including all sibling IDs and current position. + * Siblings are messages that share the same parent. + * + * @param messages - All messages in the conversation + * @param messageId - The message to get sibling info for + * @returns Sibling information including leaf node IDs for navigation + */ +export function getMessageSiblings( + messages: readonly DatabaseMessage[], + messageId: string +): ChatMessageSiblingInfo | null { + const nodeMap = new Map(); + + // Build node map for quick lookups + for (const msg of messages) { + nodeMap.set(msg.id, msg); + } + + const message = nodeMap.get(messageId); + if (!message) { + return null; + } + + // Handle null parent (root message) case + if (message.parent === null) { + // No parent means this is likely a root node with no siblings + return { + message, + siblingIds: [messageId], + currentIndex: 0, + totalSiblings: 1 + }; + } + + const parentNode = nodeMap.get(message.parent); + if (!parentNode) { + // Parent not found - treat as single message + return { + message, + siblingIds: [messageId], + currentIndex: 0, + totalSiblings: 1 + }; + } + + // Get all sibling IDs (including self) + const siblingIds = parentNode.children; + + // Convert sibling message IDs to their corresponding leaf node IDs + // This allows navigation between different conversation branches + const siblingLeafIds = siblingIds.map((siblingId: string) => findLeafNode(messages, siblingId)); + + // Find current message's position among siblings + const currentIndex = siblingIds.indexOf(messageId); + + return { + message, + siblingIds: siblingLeafIds, + currentIndex, + totalSiblings: siblingIds.length + }; +} + +/** + * Creates a display-ready list of messages with sibling information for UI rendering. + * This is the main function used by chat components to render conversation branches. + * + * @param messages - All messages in the conversation + * @param leafNodeId - Current leaf node being viewed + * @returns Array of messages with sibling navigation info + */ +export function getMessageDisplayList( + messages: readonly DatabaseMessage[], + leafNodeId: string +): ChatMessageSiblingInfo[] { + // Get the current conversation path + const currentPath = filterByLeafNodeId(messages, leafNodeId, true); + const result: ChatMessageSiblingInfo[] = []; + + // Add sibling info for each message in the current path + for (const message of currentPath) { + if (message.type === 'root') { + continue; // Skip root messages in display + } + + const siblingInfo = getMessageSiblings(messages, message.id); + if (siblingInfo) { + result.push(siblingInfo); + } + } + + return result; +} + +/** + * Checks if a message has multiple siblings (indicating branching at that point). + * + * @param messages - All messages in the conversation + * @param messageId - The message to check + * @returns True if the message has siblings + */ +export function hasMessageSiblings( + messages: readonly DatabaseMessage[], + messageId: string +): boolean { + const siblingInfo = getMessageSiblings(messages, messageId); + return siblingInfo ? siblingInfo.totalSiblings > 1 : false; +} + +/** + * Gets the next sibling message ID for navigation. + * + * @param messages - All messages in the conversation + * @param messageId - Current message ID + * @returns Next sibling's leaf node ID, or null if at the end + */ +export function getNextSibling( + messages: readonly DatabaseMessage[], + messageId: string +): string | null { + const siblingInfo = getMessageSiblings(messages, messageId); + if (!siblingInfo || siblingInfo.currentIndex >= siblingInfo.totalSiblings - 1) { + return null; + } + + return siblingInfo.siblingIds[siblingInfo.currentIndex + 1]; +} + +/** + * Gets the previous sibling message ID for navigation. + * + * @param messages - All messages in the conversation + * @param messageId - Current message ID + * @returns Previous sibling's leaf node ID, or null if at the beginning + */ +export function getPreviousSibling( + messages: readonly DatabaseMessage[], + messageId: string +): string | null { + const siblingInfo = getMessageSiblings(messages, messageId); + if (!siblingInfo || siblingInfo.currentIndex <= 0) { + return null; + } + + return siblingInfo.siblingIds[siblingInfo.currentIndex - 1]; +} diff --git a/tools/ui/src/lib/utils/browser-only.ts b/tools/ui/src/lib/utils/browser-only.ts new file mode 100644 index 000000000..27d2be4aa --- /dev/null +++ b/tools/ui/src/lib/utils/browser-only.ts @@ -0,0 +1,35 @@ +/** + * Browser-only utility exports + * + * These utilities require browser APIs (DOM, Canvas, MediaRecorder, etc.) + * and cannot be imported during SSR. Import from '$lib/utils/browser-only' + * only in client-side code or components that are not server-rendered. + */ + +// Audio utilities (MediaRecorder API) +export { + AudioRecorder, + convertToWav, + createAudioFile, + isAudioRecordingSupported +} from './audio-recording'; + +// PDF processing utilities (pdfjs-dist with DOMMatrix) +export { + convertPDFToText, + convertPDFToImage, + isPdfFile as isPdfFileFromFile, + isApplicationMimeType +} from './pdf-processing'; + +// File conversion utilities (depends on pdf-processing) +export { parseFilesToMessageExtras } from './convert-files-to-extra'; + +// File upload processing utilities (depends on pdf-processing, svg-to-png, webp-to-png) +export { processFilesToChatUploaded } from './process-uploaded-files'; + +// SVG utilities (Canvas/Image API) +export { svgBase64UrlToPngDataURL, isSvgFile, isSvgMimeType } from './svg-to-png'; + +// WebP utilities (Canvas/Image API) +export { webpBase64UrlToPngDataURL, isWebpFile, isWebpMimeType } from './webp-to-png'; diff --git a/tools/ui/src/lib/utils/cache-ttl.ts b/tools/ui/src/lib/utils/cache-ttl.ts new file mode 100644 index 000000000..4e414dd54 --- /dev/null +++ b/tools/ui/src/lib/utils/cache-ttl.ts @@ -0,0 +1,292 @@ +import { DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_MAX_ENTRIES } from '$lib/constants'; + +/** + * TTL Cache - Time-To-Live cache implementation for memory optimization + * + * Provides automatic expiration of cached entries to prevent memory bloat + * in long-running sessions. + * + * @example + * ```ts + * const cache = new TTLCache({ ttlMs: 5 * 60 * 1000 }); // 5 minutes + * cache.set('key', data); + * const value = cache.get('key'); // null if expired + * ``` + */ + +export interface TTLCacheOptions { + /** Time-to-live in milliseconds. Default: 5 minutes */ + ttlMs?: number; + /** Maximum number of entries. Oldest entries are evicted when exceeded. Default: 100 */ + maxEntries?: number; + /** Callback when an entry expires or is evicted */ + onEvict?: (key: string, value: unknown) => void; +} + +interface CacheEntry { + value: T; + expiresAt: number; + lastAccessed: number; +} + +export class TTLCache { + private cache = new Map>(); + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly onEvict?: (key: string, value: unknown) => void; + + constructor(options: TTLCacheOptions = {}) { + this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS; + this.maxEntries = options.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES; + this.onEvict = options.onEvict; + } + + /** + * Get a value from cache. Returns null if expired or not found. + */ + get(key: K): V | null { + const entry = this.cache.get(key); + if (!entry) return null; + + if (Date.now() > entry.expiresAt) { + this.delete(key); + return null; + } + + // Update last accessed time for LRU-like behavior + entry.lastAccessed = Date.now(); + return entry.value; + } + + /** + * Set a value in cache with TTL. + */ + set(key: K, value: V, customTtlMs?: number): void { + // Evict oldest entries if at capacity + if (this.cache.size >= this.maxEntries && !this.cache.has(key)) { + this.evictOldest(); + } + + const ttl = customTtlMs ?? this.ttlMs; + const now = Date.now(); + + this.cache.set(key, { + value, + expiresAt: now + ttl, + lastAccessed: now + }); + } + + /** + * Check if key exists and is not expired. + */ + has(key: K): boolean { + const entry = this.cache.get(key); + if (!entry) return false; + + if (Date.now() > entry.expiresAt) { + this.delete(key); + return false; + } + + return true; + } + + /** + * Delete a specific key from cache. + */ + delete(key: K): boolean { + const entry = this.cache.get(key); + if (entry && this.onEvict) { + this.onEvict(key, entry.value); + } + return this.cache.delete(key); + } + + /** + * Clear all entries from cache. + */ + clear(): void { + if (this.onEvict) { + for (const [key, entry] of this.cache) { + this.onEvict(key, entry.value); + } + } + this.cache.clear(); + } + + /** + * Get the number of entries (including potentially expired ones). + */ + get size(): number { + return this.cache.size; + } + + /** + * Remove all expired entries from cache. + * Call periodically for proactive cleanup. + */ + prune(): number { + const now = Date.now(); + let pruned = 0; + + for (const [key, entry] of this.cache) { + if (now > entry.expiresAt) { + this.delete(key); + pruned++; + } + } + + return pruned; + } + + /** + * Get all valid (non-expired) keys. + */ + keys(): K[] { + const now = Date.now(); + const validKeys: K[] = []; + + for (const [key, entry] of this.cache) { + if (now <= entry.expiresAt) { + validKeys.push(key); + } + } + + return validKeys; + } + + /** + * Evict the oldest (least recently accessed) entry. + */ + private evictOldest(): void { + let oldestKey: K | null = null; + let oldestTime = Infinity; + + for (const [key, entry] of this.cache) { + if (entry.lastAccessed < oldestTime) { + oldestTime = entry.lastAccessed; + oldestKey = key; + } + } + + if (oldestKey !== null) { + this.delete(oldestKey); + } + } + + /** + * Refresh TTL for an existing entry without changing the value. + */ + touch(key: K): boolean { + const entry = this.cache.get(key); + if (!entry) return false; + + const now = Date.now(); + if (now > entry.expiresAt) { + this.delete(key); + return false; + } + + entry.expiresAt = now + this.ttlMs; + entry.lastAccessed = now; + return true; + } +} + +/** + * Reactive TTL Map for Svelte stores + * Wraps SvelteMap with TTL functionality + */ +export class ReactiveTTLMap { + private entries = $state>>(new Map()); + private readonly ttlMs: number; + private readonly maxEntries: number; + + constructor(options: TTLCacheOptions = {}) { + this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS; + this.maxEntries = options.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES; + } + + get(key: K): V | null { + const entry = this.entries.get(key); + if (!entry) return null; + + if (Date.now() > entry.expiresAt) { + this.entries.delete(key); + return null; + } + + entry.lastAccessed = Date.now(); + return entry.value; + } + + set(key: K, value: V, customTtlMs?: number): void { + if (this.entries.size >= this.maxEntries && !this.entries.has(key)) { + this.evictOldest(); + } + + const ttl = customTtlMs ?? this.ttlMs; + const now = Date.now(); + + this.entries.set(key, { + value, + expiresAt: now + ttl, + lastAccessed: now + }); + } + + has(key: K): boolean { + const entry = this.entries.get(key); + if (!entry) return false; + + if (Date.now() > entry.expiresAt) { + this.entries.delete(key); + return false; + } + + return true; + } + + delete(key: K): boolean { + return this.entries.delete(key); + } + + clear(): void { + this.entries.clear(); + } + + get size(): number { + return this.entries.size; + } + + prune(): number { + const now = Date.now(); + let pruned = 0; + + for (const [key, entry] of this.entries) { + if (now > entry.expiresAt) { + this.entries.delete(key); + pruned++; + } + } + + return pruned; + } + + private evictOldest(): void { + let oldestKey: K | null = null; + let oldestTime = Infinity; + + for (const [key, entry] of this.entries) { + if (entry.lastAccessed < oldestTime) { + oldestTime = entry.lastAccessed; + oldestKey = key; + } + } + + if (oldestKey !== null) { + this.entries.delete(oldestKey); + } + } +} diff --git a/tools/ui/src/lib/utils/clipboard.ts b/tools/ui/src/lib/utils/clipboard.ts new file mode 100644 index 000000000..8fcb554b1 --- /dev/null +++ b/tools/ui/src/lib/utils/clipboard.ts @@ -0,0 +1,311 @@ +import { toast } from 'svelte-sonner'; +import { AttachmentType } from '$lib/enums'; +import type { + DatabaseMessageExtra, + DatabaseMessageExtraTextFile, + DatabaseMessageExtraLegacyContext, + DatabaseMessageExtraMcpPrompt, + DatabaseMessageExtraMcpResource, + ClipboardTextAttachment, + ClipboardMcpPromptAttachment, + ClipboardAttachment, + ParsedClipboardContent +} from '$lib/types'; + +/** + * Copy text to clipboard with toast notification + * Uses modern clipboard API when available, falls back to legacy method for non-secure contexts + * @param text - Text to copy to clipboard + * @param successMessage - Custom success message (optional) + * @param errorMessage - Custom error message (optional) + * @returns Promise - True if successful, false otherwise + */ +export async function copyToClipboard( + text: string, + successMessage = 'Copied to clipboard', + errorMessage = 'Failed to copy to clipboard' +): Promise { + try { + // Try modern clipboard API first (secure contexts only) + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + toast.success(successMessage); + return true; + } + + // Fallback for non-secure contexts + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + + if (successful) { + toast.success(successMessage); + return true; + } else { + throw new Error('execCommand failed'); + } + } catch (error) { + console.error('Failed to copy to clipboard:', error); + toast.error(errorMessage); + return false; + } +} + +/** + * Copy code with HTML entity decoding and toast notification + * @param rawCode - Raw code string that may contain HTML entities + * @param successMessage - Custom success message (optional) + * @param errorMessage - Custom error message (optional) + * @returns Promise - True if successful, false otherwise + */ +export async function copyCodeToClipboard( + rawCode: string, + successMessage = 'Code copied to clipboard', + errorMessage = 'Failed to copy code' +): Promise { + return copyToClipboard(rawCode, successMessage, errorMessage); +} + +/** + * Formats a message with text attachments for clipboard copying. + * + * Default format (asPlainText = false): + * ``` + * "Text message content" + * [ + * {"type":"TEXT","name":"filename.txt","content":"..."}, + * {"type":"TEXT","name":"another.txt","content":"..."} + * ] + * ``` + * + * Plain text format (asPlainText = true): + * ``` + * Text message content + * + * file content here + * + * another file content + * ``` + * + * @param content - The message text content + * @param extras - Optional array of message attachments + * @param asPlainText - If true, format as plain text without JSON structure + * @returns Formatted string for clipboard + */ +export function formatMessageForClipboard( + content: string, + extras?: DatabaseMessageExtra[], + asPlainText: boolean = false +): string { + // Filter text-like attachments (TEXT, LEGACY_CONTEXT, MCP_PROMPT, and MCP_RESOURCE types) + const textAttachments = + extras?.filter( + ( + extra + ): extra is + | DatabaseMessageExtraTextFile + | DatabaseMessageExtraLegacyContext + | DatabaseMessageExtraMcpPrompt + | DatabaseMessageExtraMcpResource => + extra.type === AttachmentType.TEXT || + extra.type === AttachmentType.LEGACY_CONTEXT || + extra.type === AttachmentType.MCP_PROMPT || + extra.type === AttachmentType.MCP_RESOURCE + ) ?? []; + + if (textAttachments.length === 0) { + return content; + } + + if (asPlainText) { + const parts = [content]; + for (const att of textAttachments) { + parts.push(att.content); + } + return parts.join('\n\n'); + } + + const clipboardAttachments: ClipboardAttachment[] = textAttachments.map((att) => { + if (att.type === AttachmentType.MCP_PROMPT) { + const mcpAtt = att as DatabaseMessageExtraMcpPrompt; + return { + type: AttachmentType.MCP_PROMPT, + name: mcpAtt.name, + serverName: mcpAtt.serverName, + promptName: mcpAtt.promptName, + content: mcpAtt.content, + arguments: mcpAtt.arguments + } as ClipboardMcpPromptAttachment; + } + return { + type: AttachmentType.TEXT, + name: att.name, + content: att.content + } as ClipboardTextAttachment; + }); + + return `${JSON.stringify(content)}\n${JSON.stringify(clipboardAttachments, null, 2)}`; +} + +/** + * Parses clipboard content to extract message and text attachments. + * Supports both plain text and the special format with attachments. + * + * @param clipboardText - Raw text from clipboard + * @returns Parsed content with message and attachments + */ +export function parseClipboardContent(clipboardText: string): ParsedClipboardContent { + const defaultResult: ParsedClipboardContent = { + message: clipboardText, + textAttachments: [], + mcpPromptAttachments: [] + }; + + if (!clipboardText.startsWith('"')) { + return defaultResult; + } + + try { + let stringEndIndex = -1; + let escaped = false; + + for (let i = 1; i < clipboardText.length; i++) { + const char = clipboardText[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (char === '\\') { + escaped = true; + continue; + } + + if (char === '"') { + stringEndIndex = i; + break; + } + } + + if (stringEndIndex === -1) { + return defaultResult; + } + + const jsonStringPart = clipboardText.substring(0, stringEndIndex + 1); + const remainingPart = clipboardText.substring(stringEndIndex + 1).trim(); + + const message = JSON.parse(jsonStringPart) as string; + + if (!remainingPart || !remainingPart.startsWith('[')) { + return { + message, + textAttachments: [], + mcpPromptAttachments: [] + }; + } + + const attachments = JSON.parse(remainingPart) as unknown[]; + + const validTextAttachments: ClipboardTextAttachment[] = []; + const validMcpPromptAttachments: ClipboardMcpPromptAttachment[] = []; + + for (const att of attachments) { + if (isValidMcpPromptAttachment(att)) { + validMcpPromptAttachments.push({ + type: AttachmentType.MCP_PROMPT, + name: att.name, + serverName: att.serverName, + promptName: att.promptName, + content: att.content, + arguments: att.arguments + }); + } else if (isValidTextAttachment(att)) { + validTextAttachments.push({ + type: AttachmentType.TEXT, + name: att.name, + content: att.content + }); + } + } + + return { + message, + textAttachments: validTextAttachments, + mcpPromptAttachments: validMcpPromptAttachments + }; + } catch { + return defaultResult; + } +} + +/** + * Type guard to validate an MCP prompt attachment object + * @param obj The object to validate + * @returns true if the object is a valid MCP prompt attachment + */ +function isValidMcpPromptAttachment(obj: unknown): obj is { + type: string; + name: string; + serverName: string; + promptName: string; + content: string; + arguments?: Record; +} { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const record = obj as Record; + + return ( + (record.type === AttachmentType.MCP_PROMPT || record.type === 'MCP_PROMPT') && + typeof record.name === 'string' && + typeof record.serverName === 'string' && + typeof record.promptName === 'string' && + typeof record.content === 'string' + ); +} + +/** + * Type guard to validate a text attachment object + * @param obj The object to validate + * @returns true if the object is a valid text attachment + */ +function isValidTextAttachment( + obj: unknown +): obj is { type: string; name: string; content: string } { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const record = obj as Record; + + return ( + (record.type === AttachmentType.TEXT || record.type === 'TEXT') && + typeof record.name === 'string' && + typeof record.content === 'string' + ); +} + +/** + * Checks if clipboard content contains our special format with attachments + * @param clipboardText - Raw text from clipboard + * @returns true if the clipboard content contains our special format with attachments + */ +export function hasClipboardAttachments(clipboardText: string): boolean { + if (!clipboardText.startsWith('"')) { + return false; + } + + const parsed = parseClipboardContent(clipboardText); + return parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0; +} diff --git a/tools/ui/src/lib/utils/code.ts b/tools/ui/src/lib/utils/code.ts new file mode 100644 index 000000000..d83bc31af --- /dev/null +++ b/tools/ui/src/lib/utils/code.ts @@ -0,0 +1,85 @@ +import hljs from 'highlight.js'; +import { + NEWLINE, + DEFAULT_LANGUAGE, + LANG_PATTERN, + AMPERSAND_REGEX, + LT_REGEX, + GT_REGEX, + FENCE_PATTERN +} from '$lib/constants'; + +export interface IncompleteCodeBlock { + language: string; + code: string; + openingIndex: number; +} + +/** + * Highlights code using highlight.js + * @param code - The code to highlight + * @param language - The programming language + * @returns HTML string with syntax highlighting + */ +export function highlightCode(code: string, language: string): string { + if (!code) return ''; + + try { + const lang = language.toLowerCase(); + const isSupported = hljs.getLanguage(lang); + + if (isSupported) { + return hljs.highlight(code, { language: lang }).value; + } else { + return hljs.highlightAuto(code).value; + } + } catch { + // Fallback to escaped plain text + return code + .replace(AMPERSAND_REGEX, '&') + .replace(LT_REGEX, '<') + .replace(GT_REGEX, '>'); + } +} + +/** + * Detects if markdown ends with an incomplete code block (opened but not closed). + * Returns the code block info if found, null otherwise. + * @param markdown - The raw markdown string to check + * @returns IncompleteCodeBlock info or null + */ +export function detectIncompleteCodeBlock(markdown: string): IncompleteCodeBlock | null { + // Count all code fences in the markdown + // A code block is incomplete if there's an odd number of ``` fences + const fencePattern = new RegExp(FENCE_PATTERN.source, FENCE_PATTERN.flags); + const fences: number[] = []; + let fenceMatch; + + while ((fenceMatch = fencePattern.exec(markdown)) !== null) { + // Store the position after the ``` + const pos = fenceMatch[0].startsWith(NEWLINE) ? fenceMatch.index + 1 : fenceMatch.index; + fences.push(pos); + } + + // If even number of fences (including 0), all code blocks are closed + if (fences.length % 2 === 0) { + return null; + } + + // Odd number means last code block is incomplete + // The last fence is the opening of the incomplete block + const openingIndex = fences[fences.length - 1]; + const afterOpening = markdown.slice(openingIndex + 3); + + // Extract language and code content + const langMatch = afterOpening.match(LANG_PATTERN); + const language = langMatch?.[1] || DEFAULT_LANGUAGE; + const codeStartIndex = openingIndex + 3 + (langMatch?.[0]?.length ?? 0); + const code = markdown.slice(codeStartIndex); + + return { + language, + code, + openingIndex + }; +} diff --git a/tools/ui/src/lib/utils/config-helpers.ts b/tools/ui/src/lib/utils/config-helpers.ts new file mode 100644 index 000000000..b85242d85 --- /dev/null +++ b/tools/ui/src/lib/utils/config-helpers.ts @@ -0,0 +1,51 @@ +/** + * Type-safe configuration helpers + * + * Provides utilities for safely accessing and modifying configuration objects + * with dynamic keys while maintaining TypeScript type safety. + */ + +/** + * Type-safe helper to access config properties dynamically + * Provides better type safety than direct casting to Record + */ +export function setConfigValue( + config: T, + key: string, + value: unknown +): void { + if (key in config) { + (config as Record)[key] = value; + } +} + +/** + * Type-safe helper to get config values dynamically + */ +export function getConfigValue( + config: T, + key: string +): string | number | boolean | undefined { + const value = (config as Record)[key]; + return value as string | number | boolean | undefined; +} + +/** + * Convert a SettingsConfigType to a ParameterRecord for specific keys + * Useful for parameter synchronization operations + */ +export function configToParameterRecord( + config: T, + keys: string[] +): Record { + const record: Record = {}; + + for (const key of keys) { + const value = getConfigValue(config, key); + if (value !== undefined) { + record[key] = value; + } + } + + return record; +} diff --git a/tools/ui/src/lib/utils/conversation-utils.ts b/tools/ui/src/lib/utils/conversation-utils.ts new file mode 100644 index 000000000..2c3d83899 --- /dev/null +++ b/tools/ui/src/lib/utils/conversation-utils.ts @@ -0,0 +1,31 @@ +/** + * Utility functions for conversation data manipulation + */ +import type { DatabaseMessage } from '$lib/types'; + +/** + * Creates a map of conversation IDs to their message counts from exported conversation data + * @param exportedData - Array of exported conversations with their messages + * @returns Map of conversation ID to message count + */ +export function createMessageCountMap( + exportedData: Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }> +): Map { + const countMap = new Map(); + + for (const item of exportedData) { + countMap.set(item.conv.id, item.messages.length); + } + + return countMap; +} + +/** + * Gets the message count for a specific conversation from the count map + * @param conversationId - The ID of the conversation + * @param countMap - Map of conversation IDs to message counts + * @returns The message count, or 0 if not found + */ +export function getMessageCount(conversationId: string, countMap: Map): number { + return countMap.get(conversationId) ?? 0; +} diff --git a/tools/ui/src/lib/utils/convert-files-to-extra.ts b/tools/ui/src/lib/utils/convert-files-to-extra.ts new file mode 100644 index 000000000..12be45485 --- /dev/null +++ b/tools/ui/src/lib/utils/convert-files-to-extra.ts @@ -0,0 +1,209 @@ +import { convertPDFToImage, convertPDFToText } from './pdf-processing'; +import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png'; +import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png'; +import { FileTypeCategory, AttachmentType, SpecialFileType } from '$lib/enums'; +import { SETTINGS_KEYS } from '$lib/constants'; +import { config, settingsStore } from '$lib/stores/settings.svelte'; +import { modelsStore } from '$lib/stores/models.svelte'; +import { getFileTypeCategory } from '$lib/utils'; +import { readFileAsText, isLikelyTextFile } from './text-files'; +import { toast } from 'svelte-sonner'; +import type { FileProcessingResult, ChatUploadedFile, DatabaseMessageExtra } from '$lib/types'; + +function readFileAsBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + // Extract base64 data without the data URL prefix + const dataUrl = reader.result as string; + const base64 = dataUrl.split(',')[1]; + resolve(base64); + }; + + reader.onerror = () => reject(reader.error); + + reader.readAsDataURL(file); + }); +} + +export async function parseFilesToMessageExtras( + files: ChatUploadedFile[], + activeModelId?: string +): Promise { + const extras: DatabaseMessageExtra[] = []; + const emptyFiles: string[] = []; + + for (const file of files) { + if (file.type === SpecialFileType.MCP_PROMPT && file.mcpPrompt) { + extras.push({ + type: AttachmentType.MCP_PROMPT, + name: file.name, + size: file.size, + serverName: file.mcpPrompt.serverName, + promptName: file.mcpPrompt.promptName, + content: file.textContent ?? '', + arguments: file.mcpPrompt.arguments + }); + + continue; + } + + if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) { + if (file.preview) { + let base64Url = file.preview; + + if (isSvgMimeType(file.type)) { + try { + base64Url = await svgBase64UrlToPngDataURL(base64Url); + } catch (error) { + console.error('Failed to convert SVG to PNG for database storage:', error); + } + } else if (isWebpMimeType(file.type)) { + try { + base64Url = await webpBase64UrlToPngDataURL(base64Url); + } catch (error) { + console.error('Failed to convert WebP to PNG for database storage:', error); + } + } + + extras.push({ + type: AttachmentType.IMAGE, + name: file.name, + size: file.size, + base64Url + }); + } + } else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) { + // Process audio files (MP3 and WAV) + try { + const base64Data = await readFileAsBase64(file.file); + + extras.push({ + type: AttachmentType.AUDIO, + name: file.name, + size: file.size, + base64Data: base64Data, + mimeType: file.type + }); + } catch (error) { + console.error(`Failed to process audio file ${file.name}:`, error); + } + } else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) { + try { + // Always get base64 data for preview functionality + const base64Data = await readFileAsBase64(file.file); + const currentConfig = config(); + // Use per-model vision check for router mode + const hasVisionSupport = activeModelId + ? modelsStore.modelSupportsVision(activeModelId) + : false; + + // Force PDF-to-text for non-vision models + let shouldProcessAsImages = Boolean(currentConfig.pdfAsImage) && hasVisionSupport; + + // If user had pdfAsImage enabled but model doesn't support vision, update setting and notify + if (currentConfig.pdfAsImage && !hasVisionSupport) { + console.log('Non-vision model detected: forcing PDF-to-text mode and updating settings'); + + // Update the setting in localStorage + settingsStore.updateConfig(SETTINGS_KEYS.PDF_AS_IMAGE, false); + + // Show toast notification to user + toast.warning( + 'PDF setting changed: Non-vision model detected, PDFs will be processed as text instead of images.', + { + duration: 5000 + } + ); + + shouldProcessAsImages = false; + } + + if (shouldProcessAsImages) { + // Process PDF as images (only for vision models) + try { + const images = await convertPDFToImage(file.file); + + // Show success toast for PDF image processing + toast.success( + `PDF "${file.name}" processed as ${images.length} images for vision model.`, + { + duration: 3000 + } + ); + + extras.push({ + type: AttachmentType.PDF, + name: file.name, + size: file.size, + content: `PDF file with ${images.length} pages`, + images: images, + processedAsImages: true, + base64Data: base64Data + }); + } catch (imageError) { + console.warn( + `Failed to process PDF ${file.name} as images, falling back to text:`, + imageError + ); + + // Fallback to text processing + const content = await convertPDFToText(file.file); + + extras.push({ + type: AttachmentType.PDF, + name: file.name, + size: file.size, + content: content, + processedAsImages: false, + base64Data: base64Data + }); + } + } else { + // Process PDF as text (default or forced for non-vision models) + const content = await convertPDFToText(file.file); + + // Show success toast for PDF text processing + toast.success(`PDF "${file.name}" processed as text content.`, { + duration: 3000 + }); + + extras.push({ + type: AttachmentType.PDF, + name: file.name, + size: file.size, + content: content, + processedAsImages: false, + base64Data: base64Data + }); + } + } catch (error) { + console.error(`Failed to process PDF file ${file.name}:`, error); + } + } else { + try { + const content = await readFileAsText(file.file); + + // Check if file is empty + if (content.trim() === '') { + console.warn(`File ${file.name} is empty and will be skipped`); + emptyFiles.push(file.name); + } else if (isLikelyTextFile(content)) { + extras.push({ + type: AttachmentType.TEXT, + name: file.name, + size: file.size, + content: content + }); + } else { + console.warn(`File ${file.name} appears to be binary and will be skipped`); + } + } catch (error) { + console.error(`Failed to read file ${file.name}:`, error); + } + } + } + + return { extras, emptyFiles }; +} diff --git a/tools/ui/src/lib/utils/cors-proxy.ts b/tools/ui/src/lib/utils/cors-proxy.ts new file mode 100644 index 000000000..47caf2742 --- /dev/null +++ b/tools/ui/src/lib/utils/cors-proxy.ts @@ -0,0 +1,35 @@ +/** + * CORS Proxy utility for routing requests through llama-server's CORS proxy. + */ + +import { base } from '$app/paths'; +import { CORS_PROXY_ENDPOINT, CORS_PROXY_URL_PARAM } from '$lib/constants'; + +/** + * Build a proxied URL that routes through llama-server's CORS proxy. + * @param targetUrl - The original URL to proxy + * @returns URL pointing to the CORS proxy with target encoded + */ +export function buildProxiedUrl(targetUrl: string): URL { + const proxyPath = `${base}${CORS_PROXY_ENDPOINT}`; + const proxyUrl = new URL(proxyPath, window.location.origin); + + proxyUrl.searchParams.set(CORS_PROXY_URL_PARAM, targetUrl); + + return proxyUrl; +} + +/** + * Wrap original headers for proxying through the CORS proxy. This avoids issues with duplicated llama.cpp-specific and target headers when using the CORS proxy. + * @param headers - The original headers to be proxied to target + * @returns List of "wrapped" headers to be sent to the CORS proxy + */ +export function buildProxiedHeaders(headers: Record): Record { + const proxiedHeaders: Record = {}; + + for (const [key, value] of Object.entries(headers)) { + proxiedHeaders[`x-proxy-header-${key}`] = value; + } + + return proxiedHeaders; +} diff --git a/tools/ui/src/lib/utils/css.ts b/tools/ui/src/lib/utils/css.ts new file mode 100644 index 000000000..99351a7f4 --- /dev/null +++ b/tools/ui/src/lib/utils/css.ts @@ -0,0 +1,9 @@ +/** + * Converts a rem CSS value to pixels based on the document root font size. + */ +export function remToPx(rem: string): number { + const val = parseFloat(rem); + const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); + + return val * fontSize; +} diff --git a/tools/ui/src/lib/utils/data-url.ts b/tools/ui/src/lib/utils/data-url.ts new file mode 100644 index 000000000..6f55be793 --- /dev/null +++ b/tools/ui/src/lib/utils/data-url.ts @@ -0,0 +1,10 @@ +/** + * Creates a base64 data URL from MIME type and base64-encoded data. + * + * @param mimeType - The MIME type (e.g., 'image/png', 'audio/mp3') + * @param base64Data - The base64-encoded data + * @returns A data URL string in format 'data:{mimeType};base64,{data}' + */ +export function createBase64DataUrl(mimeType: string, base64Data: string): string { + return `data:${mimeType};base64,${base64Data}`; +} diff --git a/tools/ui/src/lib/utils/debounce.ts b/tools/ui/src/lib/utils/debounce.ts new file mode 100644 index 000000000..90a5a0178 --- /dev/null +++ b/tools/ui/src/lib/utils/debounce.ts @@ -0,0 +1,22 @@ +/** + * @param fn - The function to debounce + * @param delay - The delay in milliseconds + * @returns A debounced version of the function + */ +export function debounce) => void>( + fn: T, + delay: number +): (...args: Parameters) => void { + let timeoutId: ReturnType | null = null; + + return (...args: Parameters) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + fn(...args); + timeoutId = null; + }, delay); + }; +} diff --git a/tools/ui/src/lib/utils/file-preview.ts b/tools/ui/src/lib/utils/file-preview.ts new file mode 100644 index 000000000..26a60533a --- /dev/null +++ b/tools/ui/src/lib/utils/file-preview.ts @@ -0,0 +1,36 @@ +/** + * Gets a display label for a file type from various input formats + * + * Handles: + * - MIME types: 'application/pdf' → 'PDF' + * - AttachmentType values: 'PDF', 'AUDIO' → 'PDF', 'AUDIO' + * - File names: 'document.pdf' → 'PDF' + * - Unknown: returns 'FILE' + * + * @param input - MIME type, AttachmentType value, or file name + * @returns Formatted file type label (uppercase) + */ +export function getFileTypeLabel(input: string | undefined): string { + if (!input) return 'FILE'; + + // Handle MIME types (contains '/') + if (input.includes('/')) { + const subtype = input.split('/').pop(); + if (subtype) { + // Handle special cases like 'vnd.ms-excel' → 'EXCEL' + if (subtype.includes('.')) { + return subtype.split('.').pop()?.toUpperCase() || 'FILE'; + } + return subtype.toUpperCase(); + } + } + + // Handle file names (contains '.') + if (input.includes('.')) { + const ext = input.split('.').pop(); + if (ext) return ext.toUpperCase(); + } + + // Handle AttachmentType or other plain strings + return input.toUpperCase(); +} diff --git a/tools/ui/src/lib/utils/file-type.ts b/tools/ui/src/lib/utils/file-type.ts new file mode 100644 index 000000000..4c670600c --- /dev/null +++ b/tools/ui/src/lib/utils/file-type.ts @@ -0,0 +1,222 @@ +import { + AUDIO_FILE_TYPES, + IMAGE_FILE_TYPES, + PDF_FILE_TYPES, + TEXT_FILE_TYPES +} from '$lib/constants'; +import { + FileExtensionAudio, + FileExtensionImage, + FileExtensionPdf, + FileExtensionText, + FileTypeCategory, + MimeTypeApplication, + MimeTypeAudio, + MimeTypeImage, + MimeTypeText +} from '$lib/enums'; + +export function getFileTypeCategory(mimeType: string): FileTypeCategory | null { + switch (mimeType) { + // Images + case MimeTypeImage.JPEG: + case MimeTypeImage.PNG: + case MimeTypeImage.GIF: + case MimeTypeImage.WEBP: + case MimeTypeImage.SVG: + return FileTypeCategory.IMAGE; + + // Audio + case MimeTypeAudio.MP3_MPEG: + case MimeTypeAudio.MP3: + case MimeTypeAudio.MP4: + case MimeTypeAudio.WAV: + case MimeTypeAudio.WEBM: + case MimeTypeAudio.WEBM_OPUS: + return FileTypeCategory.AUDIO; + + // PDF + case MimeTypeApplication.PDF: + return FileTypeCategory.PDF; + + // Text + case MimeTypeText.PLAIN: + case MimeTypeText.MARKDOWN: + case MimeTypeText.ASCIIDOC: + case MimeTypeText.JAVASCRIPT: + case MimeTypeText.JAVASCRIPT_APP: + case MimeTypeText.TYPESCRIPT: + case MimeTypeText.JSX: + case MimeTypeText.TSX: + case MimeTypeText.CSS: + case MimeTypeText.HTML: + case MimeTypeText.JSON: + case MimeTypeText.XML_TEXT: + case MimeTypeText.XML_APP: + case MimeTypeText.YAML_TEXT: + case MimeTypeText.YAML_APP: + case MimeTypeText.CSV: + case MimeTypeText.PYTHON: + case MimeTypeText.JAVA: + case MimeTypeText.CPP_SRC: + case MimeTypeText.C_SRC: + case MimeTypeText.C_HDR: + case MimeTypeText.PHP: + case MimeTypeText.RUBY: + case MimeTypeText.GO: + case MimeTypeText.RUST: + case MimeTypeText.SHELL: + case MimeTypeText.BAT: + case MimeTypeText.SQL: + case MimeTypeText.R: + case MimeTypeText.SCALA: + case MimeTypeText.KOTLIN: + case MimeTypeText.SWIFT: + case MimeTypeText.DART: + case MimeTypeText.VUE: + case MimeTypeText.SVELTE: + case MimeTypeText.LATEX: + case MimeTypeText.BIBTEX: + case MimeTypeText.CUDA: + case MimeTypeText.CPP_HDR: + case MimeTypeText.CSHARP: + case MimeTypeText.HASKELL: + case MimeTypeText.PROPERTIES: + case MimeTypeText.TEX: + case MimeTypeText.TEX_APP: + return FileTypeCategory.TEXT; + + default: + return null; + } +} + +export function getFileTypeCategoryByExtension(filename: string): FileTypeCategory | null { + const extension = filename.toLowerCase().substring(filename.lastIndexOf('.')); + + switch (extension) { + // Images + case FileExtensionImage.JPG: + case FileExtensionImage.JPEG: + case FileExtensionImage.PNG: + case FileExtensionImage.GIF: + case FileExtensionImage.WEBP: + case FileExtensionImage.SVG: + return FileTypeCategory.IMAGE; + + // Audio + case FileExtensionAudio.MP3: + case FileExtensionAudio.WAV: + return FileTypeCategory.AUDIO; + + // PDF + case FileExtensionPdf.PDF: + return FileTypeCategory.PDF; + + // Text + case FileExtensionText.TXT: + case FileExtensionText.MD: + case FileExtensionText.ADOC: + case FileExtensionText.JS: + case FileExtensionText.TS: + case FileExtensionText.JSX: + case FileExtensionText.TSX: + case FileExtensionText.CSS: + case FileExtensionText.HTML: + case FileExtensionText.HTM: + case FileExtensionText.JSON: + case FileExtensionText.XML: + case FileExtensionText.YAML: + case FileExtensionText.YML: + case FileExtensionText.CSV: + case FileExtensionText.LOG: + case FileExtensionText.PY: + case FileExtensionText.JAVA: + case FileExtensionText.CPP: + case FileExtensionText.C: + case FileExtensionText.H: + case FileExtensionText.PHP: + case FileExtensionText.RB: + case FileExtensionText.GO: + case FileExtensionText.RS: + case FileExtensionText.SH: + case FileExtensionText.BAT: + case FileExtensionText.SQL: + case FileExtensionText.R: + case FileExtensionText.SCALA: + case FileExtensionText.KT: + case FileExtensionText.SWIFT: + case FileExtensionText.DART: + case FileExtensionText.VUE: + case FileExtensionText.SVELTE: + case FileExtensionText.TEX: + case FileExtensionText.BIB: + case FileExtensionText.COMP: + case FileExtensionText.CU: + case FileExtensionText.CUH: + case FileExtensionText.HPP: + case FileExtensionText.HS: + case FileExtensionText.PROPERTIES: + return FileTypeCategory.TEXT; + + default: + return null; + } +} + +export function getFileTypeByExtension(filename: string): string | null { + const extension = filename.toLowerCase().substring(filename.lastIndexOf('.')); + + for (const [key, type] of Object.entries(IMAGE_FILE_TYPES)) { + if ((type.extensions as readonly string[]).includes(extension)) { + return `${FileTypeCategory.IMAGE}:${key}`; + } + } + + for (const [key, type] of Object.entries(AUDIO_FILE_TYPES)) { + if ((type.extensions as readonly string[]).includes(extension)) { + return `${FileTypeCategory.AUDIO}:${key}`; + } + } + + for (const [key, type] of Object.entries(PDF_FILE_TYPES)) { + if ((type.extensions as readonly string[]).includes(extension)) { + return `${FileTypeCategory.PDF}:${key}`; + } + } + + for (const [key, type] of Object.entries(TEXT_FILE_TYPES)) { + if ((type.extensions as readonly string[]).includes(extension)) { + return `${FileTypeCategory.TEXT}:${key}`; + } + } + + return null; +} + +export function isFileTypeSupported(filename: string, mimeType?: string): boolean { + // Images are detected and handled separately for vision models + if (mimeType) { + const category = getFileTypeCategory(mimeType); + if ( + category === FileTypeCategory.IMAGE || + category === FileTypeCategory.AUDIO || + category === FileTypeCategory.PDF + ) { + return true; + } + } + + // Check extension for known types (especially images without MIME) + const extCategory = getFileTypeCategoryByExtension(filename); + if ( + extCategory === FileTypeCategory.IMAGE || + extCategory === FileTypeCategory.AUDIO || + extCategory === FileTypeCategory.PDF + ) { + return true; + } + + // Fallback: treat everything else as text (inclusive by default) + return true; +} diff --git a/tools/ui/src/lib/utils/formatters.ts b/tools/ui/src/lib/utils/formatters.ts new file mode 100644 index 000000000..24a2c1c94 --- /dev/null +++ b/tools/ui/src/lib/utils/formatters.ts @@ -0,0 +1,153 @@ +import { + MS_PER_SECOND, + SECONDS_PER_MINUTE, + SECONDS_PER_HOUR, + SHORT_DURATION_THRESHOLD, + MEDIUM_DURATION_THRESHOLD +} from '$lib/constants'; + +/** + * Formats file size in bytes to human readable format + * Supports Bytes, KB, MB, and GB + * + * @param bytes - File size in bytes (or unknown for safety) + * @returns Formatted file size string + */ +export function formatFileSize(bytes: number | unknown): string { + if (typeof bytes !== 'number') return 'Unknown'; + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +/** + * Format parameter count to human-readable format (B, M, K) + * + * @param params - Parameter count + * @returns Human-readable parameter count + */ +export function formatParameters(params: number | unknown): string { + if (typeof params !== 'number') return 'Unknown'; + + if (params >= 1e9) { + return `${(params / 1e9).toFixed(2)}B`; + } + + if (params >= 1e6) { + return `${(params / 1e6).toFixed(2)}M`; + } + + if (params >= 1e3) { + return `${(params / 1e3).toFixed(2)}K`; + } + + return params.toString(); +} + +/** + * Format number with locale-specific thousands separators + * + * @param num - Number to format + * @returns Human-readable number + */ +export function formatNumber(num: number | unknown): string { + if (typeof num !== 'number') return 'Unknown'; + + return num.toLocaleString(); +} + +/** + * Format JSON string with pretty printing (2-space indentation) + * Returns original string if parsing fails + * + * @param jsonString - JSON string to format + * @returns Pretty-printed JSON string or original if invalid + */ +export function formatJsonPretty(jsonString: string): string { + try { + const parsed = JSON.parse(jsonString); + return JSON.stringify(parsed, null, 2); + } catch { + return jsonString; + } +} + +/** + * Format time as HH:MM:SS in 24-hour format + * + * @param date - Date object to format + * @returns Formatted time string (HH:MM:SS) + */ +export function formatTime(date: Date): string { + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); +} + +/** + * Formats milliseconds to a human-readable time string for performance metrics. + * Examples: "4h 12min 54s", "12min 34s", "45s", "0.5s" + * + * @param ms - Time in milliseconds + * @returns Formatted time string + */ +export function formatPerformanceTime(ms: number): string { + if (ms < 0) return '0s'; + + const totalSeconds = ms / MS_PER_SECOND; + + if (totalSeconds < SHORT_DURATION_THRESHOLD) { + return `${totalSeconds.toFixed(1)}s`; + } + + if (totalSeconds < MEDIUM_DURATION_THRESHOLD) { + return `${totalSeconds.toFixed(1)}s`; + } + + const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR); + const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE); + const seconds = Math.floor(totalSeconds % SECONDS_PER_MINUTE); + + const parts: string[] = []; + + if (hours > 0) { + parts.push(`${hours}h`); + } + + if (minutes > 0) { + parts.push(`${minutes}min`); + } + + if (seconds > 0 || parts.length === 0) { + parts.push(`${seconds}s`); + } + + return parts.join(' '); +} + +/** + * Formats attachment content for API requests with consistent header style. + * Used when converting message attachments to text content parts. + * + * @param label - Type label (e.g., 'File', 'PDF File', 'MCP Prompt') + * @param name - File or attachment name + * @param content - The actual content to include + * @param extra - Optional extra info to append to name (e.g., server name for MCP) + * @returns Formatted string with header and content + */ +export function formatAttachmentText( + label: string, + name: string, + content: string, + extra?: string +): string { + const header = extra ? `${name} (${extra})` : name; + return `\n\n--- ${label}: ${header} ---\n${content}`; +} diff --git a/tools/ui/src/lib/utils/headers.ts b/tools/ui/src/lib/utils/headers.ts new file mode 100644 index 000000000..0b907b830 --- /dev/null +++ b/tools/ui/src/lib/utils/headers.ts @@ -0,0 +1,44 @@ +/** + * Header utilities for parsing and serializing HTTP headers. + * Generic utilities not specific to MCP. + */ + +/** + * Parses a JSON string of headers into an array of key-value pairs. + * Returns empty array if the JSON is invalid or empty. + */ +export function parseHeadersToArray(headersJson: string): { key: string; value: string }[] { + if (!headersJson?.trim()) return []; + + try { + const parsed = JSON.parse(headersJson); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return Object.entries(parsed).map(([key, value]) => ({ + key, + value: String(value) + })); + } + } catch { + return []; + } + + return []; +} + +/** + * Serializes an array of header key-value pairs to a JSON string. + * Filters out pairs with empty keys and returns empty string if no valid pairs. + */ +export function serializeHeaders(pairs: { key: string; value: string }[]): string { + const validPairs = pairs.filter((p) => p.key.trim()); + + if (validPairs.length === 0) return ''; + + const obj: Record = {}; + + for (const pair of validPairs) { + obj[pair.key.trim()] = pair.value; + } + + return JSON.stringify(obj); +} diff --git a/tools/ui/src/lib/utils/image-error-fallback.ts b/tools/ui/src/lib/utils/image-error-fallback.ts new file mode 100644 index 000000000..6e3260f4a --- /dev/null +++ b/tools/ui/src/lib/utils/image-error-fallback.ts @@ -0,0 +1,10 @@ +/** + * Simplified HTML fallback for external images that fail to load. + * Displays a centered message with a link to open the image in a new tab. + */ +export function getImageErrorFallbackHtml(src: string): string { + return `
                + Image cannot be displayed + (open link) +
                `; +} diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts new file mode 100644 index 000000000..1c7b5476a --- /dev/null +++ b/tools/ui/src/lib/utils/index.ts @@ -0,0 +1,192 @@ +/** + * Unified exports for all utility functions + * Import utilities from '$lib/utils' for cleaner imports + * + * For browser-only utilities (pdf-processing, audio-recording, svg-to-png, + * webp-to-png, process-uploaded-files, convert-files-to-extra), use: + * import { ... } from '$lib/utils/browser-only' + */ + +// API utilities +export { getAuthHeaders, getJsonHeaders, sanitizeHeaders } from './api-headers'; +export { apiFetch, apiFetchWithParams, apiPost, type ApiFetchOptions } from './api-fetch'; +export { validateApiKey } from './api-key-validation'; + +// Attachment utilities +export { getAttachmentDisplayItems, isMcpPrompt, isMcpResource } from './attachment-display'; +export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type'; + +// Textarea utilities +export { default as autoResizeTextarea } from './autoresize-textarea'; + +// Branching utilities +export { + filterByLeafNodeId, + findMessageById, + findLeafNode, + findDescendantMessages, + getMessageSiblings, + getMessageDisplayList, + hasMessageSiblings, + getNextSibling, + getPreviousSibling +} from './branching'; + +// Code +export { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from './code'; + +// Config helpers +export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers'; + +// CORS Proxy +export { buildProxiedUrl, buildProxiedHeaders } from './cors-proxy'; + +// URL utilities +export { extractRootDomain, sanitizeExternalUrl } from './url'; + +// Conversation utilities +export { createMessageCountMap, getMessageCount } from './conversation-utils'; + +// Clipboard utilities +export { + copyToClipboard, + copyCodeToClipboard, + formatMessageForClipboard, + parseClipboardContent, + hasClipboardAttachments +} from './clipboard'; + +// File preview utilities +export { getFileTypeLabel } from './file-preview'; +export { getPreviewText, generateConversationTitle } from './text'; + +// File type utilities +export { + getFileTypeCategory, + getFileTypeCategoryByExtension, + getFileTypeByExtension, + isFileTypeSupported +} from './file-type'; + +// Formatting utilities +export { + formatFileSize, + formatParameters, + formatNumber, + formatJsonPretty, + formatTime, + formatPerformanceTime, + formatAttachmentText +} from './formatters'; + +// IME utilities +export { isIMEComposing } from './is-ime-composing'; + +// LaTeX utilities +export { maskInlineLaTeX, preprocessLaTeX } from './latex-protection'; + +// Modality file validation utilities +export { + isFileTypeSupportedByModel, + filterFilesByModalities, + generateModalityErrorMessage +} from './modality-file-validation'; + +// Model name utilities +export { normalizeModelName, isValidModelName } from './model-names'; + +// Portal utilities +export { portalToBody } from './portal-to-body'; + +// Precision utilities +export { normalizeFloatingPoint, normalizeNumber } from './precision'; + +// Syntax highlighting utilities +export { getLanguageFromFilename } from './syntax-highlight-language'; + +// Text file utilities +export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files'; + +// Debounce utilities +export { debounce } from './debounce'; + +// Sanitization utilities +export { sanitizeKeyValuePairKey, sanitizeKeyValuePairValue } from './sanitize'; + +// Image error fallback utilities +export { getImageErrorFallbackHtml } from './image-error-fallback'; + +// MCP utilities +export { + detectMcpTransportFromUrl, + parseMcpServerSettings, + getMcpLogLevelIcon, + getMcpLogLevelClass, + isImageMimeType, + parseResourcePath, + getDisplayName, + getResourceDisplayName, + isCodeResource, + isImageResource, + getResourceIcon, + getResourceTextContent, + getResourceBlobContent, + downloadResourceContent +} from './mcp'; + +// URI Template utilities +export { + extractTemplateVariables, + expandTemplate, + isTemplateComplete, + normalizeResourceUri, + type UriTemplateVariable +} from './uri-template'; + +// Data URL utilities +export { createBase64DataUrl } from './data-url'; + +// Header utilities +export { parseHeadersToArray, serializeHeaders } from './headers'; + +// Agentic content utilities (structured section derivation) +export { + deriveAgenticSections, + parseToolResultWithImages, + hasAgenticContent, + type AgenticSection, + type ToolResultLine +} from './agentic'; + +// Cache utilities +export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl'; + +// Redaction utilities +export { redactValue } from './redact'; + +// Request inspection utilities +export { + getRequestUrl, + getRequestMethod, + getRequestBody, + summarizeRequestBody, + formatDiagnosticErrorMessage, + extractJsonRpcMethods, + type RequestBodySummary +} from './request-helpers'; + +// Abort signal utilities +export { + throwIfAborted, + isAbortError, + createLinkedController, + createTimeoutSignal, + withAbortSignal +} from './abort'; + +// Cryptography utilities + +export { uuid } from './uuid'; + +// CSS utilities +export { remToPx } from './css'; diff --git a/tools/ui/src/lib/utils/is-ime-composing.ts b/tools/ui/src/lib/utils/is-ime-composing.ts new file mode 100644 index 000000000..9182ea4f3 --- /dev/null +++ b/tools/ui/src/lib/utils/is-ime-composing.ts @@ -0,0 +1,5 @@ +export function isIMEComposing(event: KeyboardEvent) { + // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari, which is notorious for not supporting KeyboardEvent.isComposing) + // This prevents form submission when confirming IME word selection (e.g., Japanese/Chinese input) + return event.isComposing || event.keyCode === 229; +} diff --git a/tools/ui/src/lib/utils/latex-protection.ts b/tools/ui/src/lib/utils/latex-protection.ts new file mode 100644 index 000000000..839306978 --- /dev/null +++ b/tools/ui/src/lib/utils/latex-protection.ts @@ -0,0 +1,270 @@ +import { + CODE_BLOCK_REGEXP, + LATEX_MATH_AND_CODE_PATTERN, + LATEX_LINEBREAK_REGEXP, + MHCHEM_PATTERN_MAP +} from '$lib/constants'; + +/** + * Replaces inline LaTeX expressions enclosed in `$...$` with placeholders, avoiding dollar signs + * that appear to be part of monetary values or identifiers. + * + * This function processes the input line by line and skips `$` sequences that are likely + * part of money amounts (e.g., `$5`, `$100.99`) or code-like tokens (e.g., `var$`, `$var`). + * Valid LaTeX inline math is replaced with a placeholder like `<>`, and the + * actual LaTeX content is stored in the provided `latexExpressions` array. + * + * @param content - The input text potentially containing LaTeX expressions. + * @param latexExpressions - An array used to collect extracted LaTeX expressions. + * @returns The processed string with LaTeX replaced by placeholders. + */ +export function maskInlineLaTeX(content: string, latexExpressions: string[]): string { + if (!content.includes('$')) { + return content; + } + return content + .split('\n') + .map((line) => { + if (line.indexOf('$') == -1) { + return line; + } + + let processedLine = ''; + let currentPosition = 0; + + while (currentPosition < line.length) { + const openDollarIndex = line.indexOf('$', currentPosition); + + if (openDollarIndex == -1) { + processedLine += line.slice(currentPosition); + break; + } + + // Is there a next $-sign? + const closeDollarIndex = line.indexOf('$', openDollarIndex + 1); + + if (closeDollarIndex == -1) { + processedLine += line.slice(currentPosition); + break; + } + + const charBeforeOpen = openDollarIndex > 0 ? line[openDollarIndex - 1] : ''; + const charAfterOpen = line[openDollarIndex + 1]; + const charBeforeClose = + openDollarIndex + 1 < closeDollarIndex ? line[closeDollarIndex - 1] : ''; + const charAfterClose = closeDollarIndex + 1 < line.length ? line[closeDollarIndex + 1] : ''; + + let shouldSkipAsNonLatex = false; + + if (closeDollarIndex == currentPosition + 1) { + // No content + shouldSkipAsNonLatex = true; + } + + if (/[A-Za-z0-9_$-]/.test(charBeforeOpen)) { + // Character, digit, $, _ or - before first '$', no TeX. + shouldSkipAsNonLatex = true; + } + + if ( + /[0-9]/.test(charAfterOpen) && + (/[A-Za-z0-9_$-]/.test(charAfterClose) || ' ' == charBeforeClose) + ) { + // First $ seems to belong to an amount. + shouldSkipAsNonLatex = true; + } + + if (shouldSkipAsNonLatex) { + processedLine += line.slice(currentPosition, openDollarIndex + 1); + currentPosition = openDollarIndex + 1; + + continue; + } + + // Treat as LaTeX + processedLine += line.slice(currentPosition, openDollarIndex); + const latexContent = line.slice(openDollarIndex, closeDollarIndex + 1); + latexExpressions.push(latexContent); + processedLine += `<>`; + currentPosition = closeDollarIndex + 1; + } + + return processedLine; + }) + .join('\n'); +} + +function escapeBrackets(text: string): string { + return text.replace( + LATEX_MATH_AND_CODE_PATTERN, + ( + match: string, + codeBlock: string | undefined, + squareBracket: string | undefined, + roundBracket: string | undefined + ): string => { + if (codeBlock != null) { + return codeBlock; + } else if (squareBracket != null) { + return `$$${squareBracket}$$`; + } else if (roundBracket != null) { + return `$${roundBracket}$`; + } + + return match; + } + ); +} + +// Escape $\\ce{...} → $\\ce{...} but with proper handling +function escapeMhchem(text: string): string { + return MHCHEM_PATTERN_MAP.reduce((result, [pattern, replacement]) => { + return result.replace(pattern, replacement); + }, text); +} + +const doEscapeMhchem = false; + +/** + * Preprocesses markdown content to safely handle LaTeX math expressions while protecting + * against false positives (e.g., dollar amounts like $5.99) and ensuring proper rendering. + * + * This function: + * - Protects code blocks (```) and inline code (`...`) + * - Safeguards block and inline LaTeX: \(...\), \[...\], $$...$$, and selective $...$ + * - Escapes standalone dollar signs before numbers (e.g., $5 → \$5) to prevent misinterpretation + * - Restores protected LaTeX and code blocks after processing + * - Converts \(...\) → $...$ and \[...\] → $$...$$ for compatibility with math renderers + * - Applies additional escaping for brackets and mhchem syntax if needed + * + * @param content - The raw text (e.g., markdown) that may contain LaTeX or code blocks. + * @returns The preprocessed string with properly escaped and normalized LaTeX. + * + * @example + * preprocessLaTeX("Price: $10. The equation is \\(x^2\\).") + * // → "Price: $10. The equation is $x^2$." + */ +export function preprocessLaTeX(content: string): string { + // See also: + // https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts + + // Step 0: Temporarily remove blockquote markers (>) to process LaTeX correctly + // Store the structure so we can restore it later + const blockquoteMarkers: Map = new Map(); + const lines = content.split('\n'); + const processedLines = lines.map((line, index) => { + const match = line.match(/^(>\s*)/); + if (match) { + blockquoteMarkers.set(index, match[1]); + return line.slice(match[1].length); + } + return line; + }); + content = processedLines.join('\n'); + + // Step 1: Protect code blocks + const codeBlocks: string[] = []; + + content = content.replace(CODE_BLOCK_REGEXP, (match) => { + codeBlocks.push(match); + + return `<>`; + }); + + // Step 2: Protect existing LaTeX expressions + const latexExpressions: string[] = []; + + // Match \S...\[...\] and protect them and insert a line-break. + content = content.replace(/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g, (match, group1, group2, group3) => { + // Check if there are characters following the formula (display-formula in a table-cell?) + if (group1.endsWith('\\')) { + return match; // Backslash before \[, do nothing. + } + const hasSuffix = /\S/.test(group3); + let optBreak; + + if (hasSuffix) { + latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline. + optBreak = ''; + } else { + latexExpressions.push(`\\[${group2}\\]`); + optBreak = '\n'; + } + + return `${group1}${optBreak}<>${optBreak}${group3}`; + }); + + // Match \(...\), \[...\], $$...$$ and protect them + content = content.replace( + /(\$\$[\s\S]*?\$\$|(? { + latexExpressions.push(match); + + return `<>`; + } + ); + + // Protect inline $...$ but NOT if it looks like money (e.g., $10, $3.99) + content = maskInlineLaTeX(content, latexExpressions); + + // Step 3: Escape standalone $ before digits (currency like $5 → \$5) + // (Now that inline math is protected, this will only escape dollars not already protected) + content = content.replace(/\$(?=\d)/g, '\\$'); + + // Step 4: Restore protected LaTeX expressions (they are valid) + content = content.replace(/<>/g, (_, index) => { + let expr = latexExpressions[parseInt(index)]; + const match = expr.match(LATEX_LINEBREAK_REGEXP); + if (match) { + // Katex: The $$-delimiters should be in their own line + // if there are \\-line-breaks. + const formula = match[1]; + const prefix = formula.startsWith('\n') ? '' : '\n'; + const suffix = formula.endsWith('\n') ? '' : '\n'; + expr = '$$' + prefix + formula + suffix + '$$'; + } + return expr; + }); + + // Step 5: Apply additional escaping functions (brackets and mhchem) + // This must happen BEFORE restoring code blocks to avoid affecting code content + content = escapeBrackets(content); + + if (doEscapeMhchem && (content.includes('\\ce{') || content.includes('\\pu{'))) { + content = escapeMhchem(content); + } + + // Step 6: Convert remaining \(...\) → $...$, \[...\] → $$...$$ + // This must happen BEFORE restoring code blocks to avoid affecting code content + content = content + // Using the look‑behind pattern `(? { + return `$$${content}$$`; + } + ); + + // Step 7: Restore code blocks + // This happens AFTER all LaTeX conversions to preserve code content + content = content.replace(/<>/g, (_, index) => { + return codeBlocks[parseInt(index)]; + }); + + // Step 8: Restore blockquote markers + if (blockquoteMarkers.size > 0) { + const finalLines = content.split('\n'); + const restoredLines = finalLines.map((line, index) => { + const marker = blockquoteMarkers.get(index); + return marker ? marker + line : line; + }); + content = restoredLines.join('\n'); + } + + return content; +} diff --git a/tools/ui/src/lib/utils/legacy-migration.ts b/tools/ui/src/lib/utils/legacy-migration.ts new file mode 100644 index 000000000..19755f6ee --- /dev/null +++ b/tools/ui/src/lib/utils/legacy-migration.ts @@ -0,0 +1,361 @@ +/** + * @deprecated Legacy migration utility — remove at some point in the future once all users have migrated to the new structured agentic message format. + * + * Converts old marker-based agentic messages to the new structured format + * with separate messages per turn. + * + * Old format: Single assistant message with markers in content: + * <<>>...<<>> + * <<>>...<<>> + * + * New format: Separate messages per turn: + * - assistant (content + reasoningContent + toolCalls) + * - tool (toolCallId + content) + * - assistant (next turn) + * - ... + */ + +import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants'; +import { DatabaseService } from '$lib/services/database.service'; +import { MessageRole, MessageType } from '$lib/enums'; +import type { DatabaseMessage } from '$lib/types/database'; + +const MIGRATION_DONE_KEY = 'llama-ui-migration-v2-done'; +/** @deprecated Use {@link MIGRATION_DONE_KEY} instead */ +const DEPRECATED_MIGRATION_DONE_KEY = 'llama-webui-migration-v2-done'; + +/** + * @deprecated Part of legacy migration — remove with the migration module. + * Check if migration has been performed. + */ +export function isMigrationNeeded(): boolean { + try { + // Check new key first, fall back to deprecated old key + if (localStorage.getItem(MIGRATION_DONE_KEY)) return false; + if (localStorage.getItem(DEPRECATED_MIGRATION_DONE_KEY)) { + // Migrate to new key + try { + localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now())); + localStorage.removeItem(DEPRECATED_MIGRATION_DONE_KEY); + } catch { + // Ignore storage errors + } + return false; + } + return true; + } catch { + return false; + } +} + +/** + * Mark migration as done. + */ +function markMigrationDone(): void { + try { + localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now())); + } catch { + // Ignore localStorage errors + } +} + +/** + * Check if a message has legacy markers in its content. + */ +function hasLegacyMarkers(message: DatabaseMessage): boolean { + if (!message.content) return false; + return LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test(message.content); +} + +/** + * Extract reasoning content from legacy marker format. + */ +function extractLegacyReasoning(content: string): { reasoning: string; cleanContent: string } { + let reasoning = ''; + let cleanContent = content; + + // Extract all reasoning blocks + const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g'); + let match; + while ((match = re.exec(content)) !== null) { + reasoning += match[1]; + } + + // Remove reasoning tags from content + cleanContent = cleanContent + .replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '') + .replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, ''); + + return { reasoning, cleanContent }; +} + +/** + * Parse legacy content with tool call markers into structured turns. + */ +interface ParsedTurn { + textBefore: string; + toolCalls: Array<{ + name: string; + args: string; + result: string; + }>; +} + +function parseLegacyToolCalls(content: string): ParsedTurn[] { + const turns: ParsedTurn[] = []; + const regex = new RegExp(LEGACY_AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g'); + + let lastIndex = 0; + let currentTurn: ParsedTurn = { textBefore: '', toolCalls: [] }; + let match; + + while ((match = regex.exec(content)) !== null) { + const textBefore = content.slice(lastIndex, match.index).trim(); + + // If there's text between tool calls and we already have tool calls, + // that means a new turn started (text after tool results = new LLM turn) + if (textBefore && currentTurn.toolCalls.length > 0) { + turns.push(currentTurn); + currentTurn = { textBefore, toolCalls: [] }; + } else if (textBefore && currentTurn.toolCalls.length === 0) { + currentTurn.textBefore = textBefore; + } + + currentTurn.toolCalls.push({ + name: match[1], + args: match[2], + result: match[3].replace(/^\n+|\n+$/g, '') + }); + + lastIndex = match.index + match[0].length; + } + + // Any remaining text after the last tool call + const remainingText = content.slice(lastIndex).trim(); + + if (currentTurn.toolCalls.length > 0) { + turns.push(currentTurn); + } + + // If there's text after all tool calls, it's the final assistant response + if (remainingText) { + // Remove any partial/open markers + const cleanRemaining = remainingText + .replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '') + .trim(); + if (cleanRemaining) { + turns.push({ textBefore: cleanRemaining, toolCalls: [] }); + } + } + + // If no tool calls found at all, return the original content as a single turn + if (turns.length === 0) { + turns.push({ textBefore: content.trim(), toolCalls: [] }); + } + + return turns; +} + +/** + * Migrate a single conversation's messages from legacy format to new format. + */ +async function migrateConversation(convId: string): Promise { + const allMessages = await DatabaseService.getConversationMessages(convId); + let migratedCount = 0; + + for (const message of allMessages) { + if (message.role !== MessageRole.ASSISTANT) continue; + if (!hasLegacyMarkers(message)) { + // Still check for reasoning-only markers (no tool calls) + if (message.content?.includes(LEGACY_REASONING_TAGS.START)) { + const { reasoning, cleanContent } = extractLegacyReasoning(message.content); + await DatabaseService.updateMessage(message.id, { + content: cleanContent.trim(), + reasoningContent: reasoning || undefined + }); + migratedCount++; + } + continue; + } + + // Has agentic markers - full migration needed + const { reasoning, cleanContent } = extractLegacyReasoning(message.content); + const turns = parseLegacyToolCalls(cleanContent); + + // Parse existing toolCalls JSON to try to match IDs + let existingToolCalls: Array<{ + id: string; + function?: { name: string; arguments: string }; + }> = []; + if (message.toolCalls) { + try { + existingToolCalls = JSON.parse(message.toolCalls); + } catch { + // Ignore + } + } + + // First turn uses the existing message + const firstTurn = turns[0]; + if (!firstTurn) continue; + + // Match tool calls from the first turn to existing IDs + const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => { + const existing = + existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i]; + return { + id: existing?.id || `legacy_tool_${i}`, + type: 'function' as const, + function: { name: tc.name, arguments: tc.args } + }; + }); + + // Update the existing message for the first turn + await DatabaseService.updateMessage(message.id, { + content: firstTurn.textBefore, + reasoningContent: reasoning || undefined, + toolCalls: firstTurnToolCalls.length > 0 ? JSON.stringify(firstTurnToolCalls) : '' + }); + + let currentParentId = message.id; + let toolCallIdCounter = existingToolCalls.length; + + // Create tool result messages for the first turn + for (let i = 0; i < firstTurn.toolCalls.length; i++) { + const tc = firstTurn.toolCalls[i]; + const toolCallId = firstTurnToolCalls[i]?.id || `legacy_tool_${i}`; + + const toolMsg = await DatabaseService.createMessageBranch( + { + convId, + type: MessageType.TEXT, + role: MessageRole.TOOL, + content: tc.result, + toolCallId, + timestamp: message.timestamp + i + 1, + toolCalls: '', + children: [] + }, + currentParentId + ); + currentParentId = toolMsg.id; + } + + // Create messages for subsequent turns + for (let turnIdx = 1; turnIdx < turns.length; turnIdx++) { + const turn = turns[turnIdx]; + + const turnToolCalls = turn.toolCalls.map((tc, i) => { + const idx = toolCallIdCounter + i; + const existing = existingToolCalls[idx]; + return { + id: existing?.id || `legacy_tool_${idx}`, + type: 'function' as const, + function: { name: tc.name, arguments: tc.args } + }; + }); + toolCallIdCounter += turn.toolCalls.length; + + // Create assistant message for this turn + const assistantMsg = await DatabaseService.createMessageBranch( + { + convId, + type: MessageType.TEXT, + role: MessageRole.ASSISTANT, + content: turn.textBefore, + timestamp: message.timestamp + turnIdx * 100, + toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '', + children: [], + model: message.model + }, + currentParentId + ); + currentParentId = assistantMsg.id; + + // Create tool result messages for this turn + for (let i = 0; i < turn.toolCalls.length; i++) { + const tc = turn.toolCalls[i]; + const toolCallId = turnToolCalls[i]?.id || `legacy_tool_${toolCallIdCounter + i}`; + + const toolMsg = await DatabaseService.createMessageBranch( + { + convId, + type: MessageType.TEXT, + role: MessageRole.TOOL, + content: tc.result, + toolCallId, + timestamp: message.timestamp + turnIdx * 100 + i + 1, + toolCalls: '', + children: [] + }, + currentParentId + ); + currentParentId = toolMsg.id; + } + } + + // Re-parent any children of the original message to the last created message + // (the original message's children list was the next user message or similar) + if (message.children.length > 0 && currentParentId !== message.id) { + for (const childId of message.children) { + // Skip children we just created (they were already properly parented) + const child = allMessages.find((m) => m.id === childId); + if (!child) continue; + // Only re-parent non-tool messages that were original children + if (child.role !== MessageRole.TOOL) { + await DatabaseService.updateMessage(childId, { parent: currentParentId }); + // Add to new parent's children + const newParent = await DatabaseService.getConversationMessages(convId).then((msgs) => + msgs.find((m) => m.id === currentParentId) + ); + if (newParent && !newParent.children.includes(childId)) { + await DatabaseService.updateMessage(currentParentId, { + children: [...newParent.children, childId] + }); + } + } + } + // Clear re-parented children from the original message + await DatabaseService.updateMessage(message.id, { children: [] }); + } + + migratedCount++; + } + + return migratedCount; +} + +/** + * @deprecated Part of legacy migration — remove with the migration module. + * Run the full migration across all conversations. + * This should be called once at app startup if migration is needed. + */ +export async function runLegacyMigration(): Promise { + if (!isMigrationNeeded()) return; + + console.log('[Migration] Starting legacy message format migration...'); + + try { + const conversations = await DatabaseService.getAllConversations(); + let totalMigrated = 0; + + for (const conv of conversations) { + const count = await migrateConversation(conv.id); + totalMigrated += count; + } + + if (totalMigrated > 0) { + console.log( + `[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations` + ); + } else { + console.log('[Migration] No legacy messages found, marking as done'); + } + + markMigrationDone(); + } catch (error) { + console.error('[Migration] Failed to migrate legacy messages:', error); + // Still mark as done to avoid infinite retry loops + markMigrationDone(); + } +} diff --git a/tools/ui/src/lib/utils/mcp.ts b/tools/ui/src/lib/utils/mcp.ts new file mode 100644 index 000000000..ee2779845 --- /dev/null +++ b/tools/ui/src/lib/utils/mcp.ts @@ -0,0 +1,304 @@ +import type { MCPServerSettingsEntry, MCPResourceContent, MCPResourceInfo } from '$lib/types'; +import { + MCPTransportType, + MCPLogLevel, + UrlProtocol, + MimeTypePrefix, + MimeTypeIncludes, + UriPattern, + MimeTypeText +} from '$lib/enums'; +import { + DEFAULT_MCP_CONFIG, + MCP_SERVER_ID_PREFIX, + IMAGE_FILE_EXTENSION_REGEX, + CODE_FILE_EXTENSION_REGEX, + TEXT_FILE_EXTENSION_REGEX, + PROTOCOL_PREFIX_REGEX, + FILE_EXTENSION_REGEX, + DISPLAY_NAME_SEPARATOR_REGEX, + PATH_SEPARATOR, + RESOURCE_TEXT_CONTENT_SEPARATOR, + DEFAULT_RESOURCE_FILENAME +} from '$lib/constants'; +import { + Database, + File, + FileText, + Image, + Code, + Info, + AlertTriangle, + XCircle +} from '@lucide/svelte'; +import type { Component } from 'svelte'; +import type { MimeTypeUnion } from '$lib/types/common'; + +/** + * Detects the MCP transport type from a URL. + * WebSocket URLs (ws:// or wss://) use 'websocket', others use 'streamable_http'. + */ +export function detectMcpTransportFromUrl(url: string): MCPTransportType { + const normalized = url.trim().toLowerCase(); + + return normalized.startsWith(UrlProtocol.WEBSOCKET) || + normalized.startsWith(UrlProtocol.WEBSOCKET_SECURE) + ? MCPTransportType.WEBSOCKET + : MCPTransportType.STREAMABLE_HTTP; +} + +/** + * Parses MCP server settings from a JSON string or array. + * requestTimeoutSeconds is not user-configurable in the UI, so we always use the default value. + * @param rawServers - The raw servers to parse + * @returns An empty array if the input is invalid. + */ +export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEntry[] { + if (!rawServers) return []; + + let parsed: unknown; + + if (typeof rawServers === 'string') { + const trimmed = rawServers.trim(); + if (!trimmed) return []; + + try { + parsed = JSON.parse(trimmed); + } catch (error) { + console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error); + + return []; + } + } else { + parsed = rawServers; + } + + if (!Array.isArray(parsed)) return []; + + return parsed.map((entry, index) => { + const url = typeof entry?.url === 'string' ? entry.url.trim() : ''; + const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined; + const id = + typeof (entry as { id?: unknown })?.id === 'string' && (entry as { id?: string }).id?.trim() + ? (entry as { id: string }).id.trim() + : `${MCP_SERVER_ID_PREFIX}-${index + 1}`; + + return { + id, + enabled: Boolean((entry as { enabled?: unknown })?.enabled), + url, + name: (entry as { name?: string })?.name, + requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds, + headers: headers || undefined, + useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy) + } satisfies MCPServerSettingsEntry; + }); +} + +/** + * Get the appropriate icon component for a log level + * + * @param level - MCP log level + * @returns Lucide icon component + */ +export function getMcpLogLevelIcon(level: MCPLogLevel): Component { + switch (level) { + case MCPLogLevel.ERROR: + return XCircle; + case MCPLogLevel.WARN: + return AlertTriangle; + default: + return Info; + } +} + +/** + * Get the appropriate CSS class for a log level + * + * @param level - MCP log level + * @returns Tailwind CSS class string + */ +export function getMcpLogLevelClass(level: MCPLogLevel): string { + switch (level) { + case MCPLogLevel.ERROR: + return 'text-destructive'; + case MCPLogLevel.WARN: + return 'text-yellow-600 dark:text-yellow-500'; + default: + return 'text-muted-foreground'; + } +} + +/** + * Check if a MIME type represents an image. + * + * @param mimeType - The MIME type to check + * @returns True if the MIME type starts with 'image/' + */ +export function isImageMimeType(mimeType?: MimeTypeUnion): boolean { + return mimeType?.startsWith(MimeTypePrefix.IMAGE) ?? false; +} + +/** + * Parse a resource URI into path segments, stripping the protocol prefix. + * + * @param uri - The resource URI to parse + * @returns Array of non-empty path segments + */ +export function parseResourcePath(uri: string): string[] { + try { + const withoutProtocol = uri.replace(PROTOCOL_PREFIX_REGEX, ''); + return withoutProtocol.split(PATH_SEPARATOR).filter((p) => p.length > 0); + } catch { + return [uri]; + } +} + +/** + * Convert a path part into a human-readable display name. + * Strips file extensions and converts kebab-case/snake_case to Title Case. + * + * @param pathPart - The path segment to convert + * @returns Human-readable display name + */ +export function getDisplayName(pathPart: string): string { + const withoutExt = pathPart.replace(FILE_EXTENSION_REGEX, ''); + return withoutExt + .split(DISPLAY_NAME_SEPARATOR_REGEX) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +/** + * Get the display name from a resource, extracting the last path segment from the URI. + * + * @param resource - The MCP resource info + * @returns Display name string + */ +export function getResourceDisplayName(resource: MCPResourceInfo): string { + try { + const parts = parseResourcePath(resource.uri); + return parts[parts.length - 1] || resource.name || resource.uri; + } catch { + return resource.name || resource.uri; + } +} + +/** + * Determine if a MIME type and/or URI represents code content. + * + * @param mimeType - Optional MIME type string + * @param uri - Optional URI string + * @returns True if the content is code + */ +export function isCodeResource(mimeType?: MimeTypeUnion, uri?: string): boolean { + const mime = mimeType?.toLowerCase() || ''; + const u = uri?.toLowerCase() || ''; + return ( + mime.includes(MimeTypeIncludes.JSON) || + mime.includes(MimeTypeIncludes.JAVASCRIPT) || + mime.includes(MimeTypeIncludes.TYPESCRIPT) || + CODE_FILE_EXTENSION_REGEX.test(u) + ); +} + +/** + * Determine if a MIME type and/or URI represents image content. + * + * @param mimeType - Optional MIME type string + * @param uri - Optional URI string + * @returns True if the content is an image + */ +export function isImageResource(mimeType?: MimeTypeUnion, uri?: string): boolean { + const mime = mimeType?.toLowerCase() || ''; + const u = uri?.toLowerCase() || ''; + return mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u); +} + +/** + * Get the appropriate Lucide icon component for an MCP resource based on its MIME type and URI. + * + * @param mimeType - Optional MIME type of the resource + * @param uri - Optional URI of the resource + * @returns Lucide icon component + */ +export function getResourceIcon(mimeType?: MimeTypeUnion, uri?: string): Component { + const mime = mimeType?.toLowerCase() || ''; + const u = uri?.toLowerCase() || ''; + + if (mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u)) { + return Image; + } + + if ( + mime.includes(MimeTypeIncludes.JSON) || + mime.includes(MimeTypeIncludes.JAVASCRIPT) || + mime.includes(MimeTypeIncludes.TYPESCRIPT) || + CODE_FILE_EXTENSION_REGEX.test(u) + ) { + return Code; + } + + if (mime.includes(MimeTypePrefix.TEXT) || TEXT_FILE_EXTENSION_REGEX.test(u)) { + return FileText; + } + + if (u.includes(UriPattern.DATABASE_KEYWORD) || u.includes(UriPattern.DATABASE_SCHEME)) { + return Database; + } + + return File; +} + +/** + * Extract text content from MCP resource content array. + * + * @param content - Array of MCP resource content items + * @returns Joined text content string + */ +export function getResourceTextContent(content: MCPResourceContent[] | null | undefined): string { + if (!content) return ''; + return content + .filter((c): c is { uri: string; mimeType?: MimeTypeUnion; text: string } => 'text' in c) + .map((c) => c.text) + .join(RESOURCE_TEXT_CONTENT_SEPARATOR); +} + +/** + * Extract blob content from MCP resource content array. + * + * @param content - Array of MCP resource content items + * @returns Array of blob content items + */ +export function getResourceBlobContent( + content: MCPResourceContent[] | null | undefined +): Array<{ uri: string; mimeType?: MimeTypeUnion; blob: string }> { + if (!content) return []; + + return content.filter( + (c): c is { uri: string; mimeType?: MimeTypeUnion; blob: string } => 'blob' in c + ); +} + +/** + * Trigger a file download from text content. + * + * @param text - The text content to download + * @param mimeType - MIME type for the blob + * @param filename - Suggested filename + */ +export function downloadResourceContent( + text: string, + mimeType: MimeTypeUnion = MimeTypeText.PLAIN, + filename: string = DEFAULT_RESOURCE_FILENAME +): void { + const blob = new Blob([text], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/tools/ui/src/lib/utils/modality-file-validation.ts b/tools/ui/src/lib/utils/modality-file-validation.ts new file mode 100644 index 000000000..9b52e93db --- /dev/null +++ b/tools/ui/src/lib/utils/modality-file-validation.ts @@ -0,0 +1,157 @@ +/** + * File validation utilities based on model modalities + * Ensures only compatible file types are processed based on model capabilities + */ + +import { getFileTypeCategory } from '$lib/utils'; +import { FileTypeCategory } from '$lib/enums'; +import type { ModalityCapabilities } from '$lib/types'; + +/** + * Check if a file type is supported by the given modalities + * @param filename - The filename to check + * @param mimeType - The MIME type of the file + * @param capabilities - The modality capabilities to check against + * @returns true if the file type is supported + */ +export function isFileTypeSupportedByModel( + filename: string, + mimeType: string | undefined, + capabilities: ModalityCapabilities +): boolean { + const category = mimeType ? getFileTypeCategory(mimeType) : null; + + // If we can't determine the category from MIME type, fall back to general support check + if (!category) { + // For unknown types, only allow if they might be text files + // This is a conservative approach for edge cases + return true; // Let the existing isFileTypeSupported handle this + } + + switch (category) { + case FileTypeCategory.TEXT: + // Text files are always supported + return true; + + case FileTypeCategory.PDF: + // PDFs are always supported (will be processed as text for non-vision models) + return true; + + case FileTypeCategory.IMAGE: + // Images require vision support + return capabilities.hasVision; + + case FileTypeCategory.AUDIO: + // Audio files require audio support + return capabilities.hasAudio; + + default: + // Unknown categories - be conservative and allow + return true; + } +} + +/** + * Filter files based on model modalities and return supported/unsupported lists + * @param files - Array of files to filter + * @param capabilities - The modality capabilities to check against + * @returns Object with supportedFiles and unsupportedFiles arrays + */ +export function filterFilesByModalities( + files: File[], + capabilities: ModalityCapabilities +): { + supportedFiles: File[]; + unsupportedFiles: File[]; + modalityReasons: Record; +} { + const supportedFiles: File[] = []; + const unsupportedFiles: File[] = []; + const modalityReasons: Record = {}; + + const { hasVision, hasAudio } = capabilities; + + for (const file of files) { + const category = getFileTypeCategory(file.type); + let isSupported = true; + let reason = ''; + + switch (category) { + case FileTypeCategory.IMAGE: + if (!hasVision) { + isSupported = false; + reason = 'Images require a vision-capable model'; + } + break; + + case FileTypeCategory.AUDIO: + if (!hasAudio) { + isSupported = false; + reason = 'Audio files require an audio-capable model'; + } + break; + + case FileTypeCategory.TEXT: + case FileTypeCategory.PDF: + // Always supported + break; + + default: + // For unknown types, check if it's a generally supported file type + // This handles edge cases and maintains backward compatibility + break; + } + + if (isSupported) { + supportedFiles.push(file); + } else { + unsupportedFiles.push(file); + modalityReasons[file.name] = reason; + } + } + + return { supportedFiles, unsupportedFiles, modalityReasons }; +} + +/** + * Generate a user-friendly error message for unsupported files + * @param unsupportedFiles - Array of unsupported files + * @param modalityReasons - Reasons why files are unsupported + * @param capabilities - The modality capabilities to check against + * @returns Formatted error message + */ +export function generateModalityErrorMessage( + unsupportedFiles: File[], + modalityReasons: Record, + capabilities: ModalityCapabilities +): string { + if (unsupportedFiles.length === 0) return ''; + + const { hasVision, hasAudio } = capabilities; + + let message = ''; + + if (unsupportedFiles.length === 1) { + const file = unsupportedFiles[0]; + const reason = modalityReasons[file.name]; + message = `The file "${file.name}" cannot be uploaded: ${reason}.`; + } else { + const fileNames = unsupportedFiles.map((f) => f.name).join(', '); + message = `The following files cannot be uploaded: ${fileNames}.`; + } + + // Add helpful information about what is supported + const supportedTypes: string[] = ['text files', 'PDFs']; + if (hasVision) supportedTypes.push('images'); + if (hasAudio) supportedTypes.push('audio files'); + + message += ` This model supports: ${supportedTypes.join(', ')}.`; + + return message; +} + +/** + * Generate file input accept string based on model modalities + * @param capabilities - The modality capabilities to check against + * @returns Accept string for HTML file input element + */ diff --git a/tools/ui/src/lib/utils/model-names.ts b/tools/ui/src/lib/utils/model-names.ts new file mode 100644 index 000000000..c0a1e1c57 --- /dev/null +++ b/tools/ui/src/lib/utils/model-names.ts @@ -0,0 +1,56 @@ +/** + * Normalizes a model name by extracting the filename from a path, but preserves Hugging Face repository format. + * + * Handles both forward slashes (/) and backslashes (\) as path separators. + * - If the model name has exactly one slash (org/model format), preserves the full "org/model" name + * - If the model name has no slash or multiple slashes, extracts just the filename + * - If the model name is just a filename (no path), returns it as-is. + * + * @param modelName - The model name or path to normalize + * @returns The normalized model name + * + * @example + * normalizeModelName('models/llama-3.1-8b') // Returns: 'llama-3.1-8b' (multiple slashes -> filename) + * normalizeModelName('C:\\Models\\gpt-4') // Returns: 'gpt-4' (multiple slashes -> filename) + * normalizeModelName('meta-llama/Llama-3.1-8B') // Returns: 'meta-llama/Llama-3.1-8B' (Hugging Face format) + * normalizeModelName('simple-model') // Returns: 'simple-model' (no slash) + * normalizeModelName(' spaced ') // Returns: 'spaced' + * normalizeModelName('') // Returns: '' + */ +export function normalizeModelName(modelName: string): string { + const trimmed = modelName.trim(); + + if (!trimmed) { + return ''; + } + + const segments = trimmed.split(/[\\/]/); + + // If we have exactly 2 segments (one slash), treat it as Hugging Face repo format + // and preserve the full "org/model" format + if (segments.length === 2) { + const [org, model] = segments; + const trimmedOrg = org?.trim(); + const trimmedModel = model?.trim(); + + if (trimmedOrg && trimmedModel) { + return `${trimmedOrg}/${trimmedModel}`; + } + } + + // For other cases (no slash, or multiple slashes), extract just the filename + const candidate = segments.pop(); + const normalized = candidate?.trim(); + + return normalized && normalized.length > 0 ? normalized : trimmed; +} + +/** + * Validates if a model name is valid (non-empty after normalization). + * + * @param modelName - The model name to validate + * @returns true if valid, false otherwise + */ +export function isValidModelName(modelName: string): boolean { + return normalizeModelName(modelName).length > 0; +} diff --git a/tools/ui/src/lib/utils/pdf-processing.ts b/tools/ui/src/lib/utils/pdf-processing.ts new file mode 100644 index 000000000..84c456d10 --- /dev/null +++ b/tools/ui/src/lib/utils/pdf-processing.ts @@ -0,0 +1,150 @@ +/** + * PDF processing utilities using PDF.js + * Handles PDF text extraction and image conversion in the browser + */ + +import { browser } from '$app/environment'; +import { MimeTypeApplication, MimeTypeImage } from '$lib/enums'; +import * as pdfjs from 'pdfjs-dist'; + +type TextContent = { + items: Array<{ str: string }>; +}; + +if (browser) { + // Import worker as text and create blob URL for inline bundling + import('pdfjs-dist/build/pdf.worker.min.mjs?raw') + .then((workerModule) => { + const workerBlob = new Blob([workerModule.default], { type: 'application/javascript' }); + pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(workerBlob); + }) + .catch(() => { + console.warn('Failed to load PDF.js worker, PDF processing may not work'); + }); +} + +/** + * Convert a File object to ArrayBuffer for PDF.js processing + * @param file - The PDF file to convert + * @returns Promise resolving to the file's ArrayBuffer + */ +async function getFileAsBuffer(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + if (event.target?.result) { + resolve(event.target.result as ArrayBuffer); + } else { + reject(new Error('Failed to read file.')); + } + }; + reader.onerror = () => { + reject(new Error('Failed to read file.')); + }; + reader.readAsArrayBuffer(file); + }); +} + +/** + * Extract text content from a PDF file + * @param file - The PDF file to process + * @returns Promise resolving to the extracted text content + */ +export async function convertPDFToText(file: File): Promise { + if (!browser) { + throw new Error('PDF processing is only available in the browser'); + } + + try { + const buffer = await getFileAsBuffer(file); + const pdf = await pdfjs.getDocument(buffer).promise; + const numPages = pdf.numPages; + + const textContentPromises: Promise[] = []; + + for (let i = 1; i <= numPages; i++) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + textContentPromises.push(pdf.getPage(i).then((page: any) => page.getTextContent())); + } + + const textContents = await Promise.all(textContentPromises); + const textItems = textContents.flatMap((textContent: TextContent) => + textContent.items.map((item) => item.str ?? '') + ); + + return textItems.join('\n'); + } catch (error) { + console.error('Error converting PDF to text:', error); + throw new Error( + `Failed to convert PDF to text: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Convert PDF pages to PNG images as data URLs + * @param file - The PDF file to convert + * @param scale - Rendering scale factor (default: 1.5) + * @returns Promise resolving to array of PNG data URLs + */ +export async function convertPDFToImage(file: File, scale: number = 1.5): Promise { + if (!browser) { + throw new Error('PDF processing is only available in the browser'); + } + + try { + const buffer = await getFileAsBuffer(file); + const doc = await pdfjs.getDocument(buffer).promise; + const pages: Promise[] = []; + + for (let i = 1; i <= doc.numPages; i++) { + const page = await doc.getPage(i); + const viewport = page.getViewport({ scale }); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + canvas.width = viewport.width; + canvas.height = viewport.height; + + if (!ctx) { + throw new Error('Failed to get 2D context from canvas'); + } + + const task = page.render({ + canvasContext: ctx, + viewport: viewport, + canvas: canvas + }); + pages.push( + task.promise.then(() => { + return canvas.toDataURL(MimeTypeImage.PNG); + }) + ); + } + + return await Promise.all(pages); + } catch (error) { + console.error('Error converting PDF to images:', error); + throw new Error( + `Failed to convert PDF to images: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Check if a file is a PDF based on its MIME type + * @param file - The file to check + * @returns True if the file is a PDF + */ +export function isPdfFile(file: File): boolean { + return file.type === MimeTypeApplication.PDF; +} + +/** + * Check if a MIME type represents a PDF + * @param mimeType - The MIME type to check + * @returns True if the MIME type is application/pdf + */ +export function isApplicationMimeType(mimeType: string): boolean { + return mimeType === MimeTypeApplication.PDF; +} diff --git a/tools/ui/src/lib/utils/portal-to-body.ts b/tools/ui/src/lib/utils/portal-to-body.ts new file mode 100644 index 000000000..bffbe8900 --- /dev/null +++ b/tools/ui/src/lib/utils/portal-to-body.ts @@ -0,0 +1,20 @@ +export function portalToBody(node: HTMLElement) { + if (typeof document === 'undefined') { + return; + } + + const target = document.body; + if (!target) { + return; + } + + target.appendChild(node); + + return { + destroy() { + if (node.parentNode === target) { + target.removeChild(node); + } + } + }; +} diff --git a/tools/ui/src/lib/utils/precision.ts b/tools/ui/src/lib/utils/precision.ts new file mode 100644 index 000000000..500281dc9 --- /dev/null +++ b/tools/ui/src/lib/utils/precision.ts @@ -0,0 +1,25 @@ +/** + * Floating-point precision utilities + * + * Provides functions to normalize floating-point numbers for consistent comparison + * and display, addressing JavaScript's floating-point precision issues. + */ + +import { PRECISION_MULTIPLIER } from '$lib/constants'; + +/** + * Normalize floating-point numbers for consistent comparison + * Addresses JavaScript floating-point precision issues (e.g., 0.949999988079071 → 0.95) + */ +export function normalizeFloatingPoint(value: unknown): unknown { + return typeof value === 'number' + ? Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER + : value; +} + +/** + * Type-safe version that only accepts numbers + */ +export function normalizeNumber(value: number): number { + return Math.round(value * PRECISION_MULTIPLIER) / PRECISION_MULTIPLIER; +} diff --git a/tools/ui/src/lib/utils/process-uploaded-files.ts b/tools/ui/src/lib/utils/process-uploaded-files.ts new file mode 100644 index 000000000..1f4068aee --- /dev/null +++ b/tools/ui/src/lib/utils/process-uploaded-files.ts @@ -0,0 +1,137 @@ +import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png'; +import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png'; +import { FileTypeCategory } from '$lib/enums'; +import { SETTINGS_KEYS } from '$lib/constants'; +import { modelsStore } from '$lib/stores/models.svelte'; +import { settingsStore } from '$lib/stores/settings.svelte'; +import { toast } from 'svelte-sonner'; +import { getFileTypeCategory } from '$lib/utils'; +import { convertPDFToText } from './pdf-processing'; + +/** + * Read a file as a data URL (base64 encoded) + * @param file - The file to read + * @returns Promise resolving to the data URL string + */ +function readFileAsDataURL(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); +} + +/** + * Read a file as UTF-8 text + * @param file - The file to read + * @returns Promise resolving to the text content + */ +function readFileAsUTF8(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +} + +/** + * Process uploaded files into ChatUploadedFile format with previews and content + * + * This function processes various file types and generates appropriate previews: + * - Images: Base64 data URLs with format normalization (SVG/WebP → PNG) + * - Text files: UTF-8 content extraction + * - PDFs: Metadata only (processed later in conversion pipeline) + * - Audio: Base64 data URLs for preview + * + * @param files - Array of File objects to process + * @returns Promise resolving to array of ChatUploadedFile objects + */ +export async function processFilesToChatUploaded( + files: File[], + activeModelId?: string +): Promise { + const results: ChatUploadedFile[] = []; + + for (const file of files) { + const id = Date.now().toString() + Math.random().toString(36).substr(2, 9); + const base: ChatUploadedFile = { + id, + name: file.name, + size: file.size, + type: file.type, + file + }; + + try { + if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) { + let preview = await readFileAsDataURL(file); + + // Normalize SVG and WebP to PNG in previews + if (isSvgMimeType(file.type)) { + try { + preview = await svgBase64UrlToPngDataURL(preview); + } catch (err) { + console.error('Failed to convert SVG to PNG:', err); + } + } else if (isWebpMimeType(file.type)) { + try { + preview = await webpBase64UrlToPngDataURL(preview); + } catch (err) { + console.error('Failed to convert WebP to PNG:', err); + } + } + + results.push({ ...base, preview }); + } else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) { + // Extract text content from PDF for preview + try { + const textContent = await convertPDFToText(file); + results.push({ ...base, textContent }); + } catch (err) { + console.warn('Failed to extract text from PDF, adding without content:', err); + results.push(base); + } + + // Show suggestion toast if vision model is available but PDF as image is disabled + const hasVisionSupport = activeModelId + ? modelsStore.modelSupportsVision(activeModelId) + : false; + const currentConfig = settingsStore.config; + if (hasVisionSupport && !currentConfig.pdfAsImage) { + toast.info(`You can enable parsing PDF as images with vision models.`, { + duration: 8000, + action: { + label: 'Enable PDF as Images', + onClick: () => { + settingsStore.updateConfig(SETTINGS_KEYS.PDF_AS_IMAGE, true); + toast.success('PDF parsing as images enabled!', { + duration: 3000 + }); + } + } + }); + } + } else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) { + // Generate preview URL for audio files + const preview = await readFileAsDataURL(file); + results.push({ ...base, preview }); + } else { + // Fallback: treat unknown files as text + try { + const textContent = await readFileAsUTF8(file); + results.push({ ...base, textContent }); + } catch (err) { + console.warn('Failed to read file as text, adding without content:', err); + results.push(base); + } + } + } catch (error) { + console.error('Error processing file', file.name, error); + results.push(base); + } + } + + return results; +} diff --git a/tools/ui/src/lib/utils/redact.ts b/tools/ui/src/lib/utils/redact.ts new file mode 100644 index 000000000..851be7bf4 --- /dev/null +++ b/tools/ui/src/lib/utils/redact.ts @@ -0,0 +1,14 @@ +/** + * Redacts a sensitive value, optionally showing the last N characters. + * + * @param value - The value to redact + * @param showLastChars - If provided, reveals the last N characters with a leading mask + * @returns The redacted string + */ +export function redactValue(value: string, showLastChars?: number): string { + if (showLastChars) { + return `....${value.slice(-showLastChars)}`; + } + + return '[redacted]'; +} diff --git a/tools/ui/src/lib/utils/request-helpers.ts b/tools/ui/src/lib/utils/request-helpers.ts new file mode 100644 index 000000000..8a11b8fb5 --- /dev/null +++ b/tools/ui/src/lib/utils/request-helpers.ts @@ -0,0 +1,111 @@ +/** + * HTTP request inspection utilities for diagnostic logging. + * These helpers extract metadata from fetch-style request arguments + * without exposing sensitive payload data. + */ + +export interface RequestBodySummary { + kind: string; + size?: number; +} + +export function getRequestUrl(input: RequestInfo | URL): string { + if (typeof input === 'string') { + return input; + } + + if (input instanceof URL) { + return input.href; + } + + return input.url; +} + +export function getRequestMethod( + input: RequestInfo | URL, + init?: RequestInit, + baseInit?: RequestInit +): string { + if (init?.method) { + return init.method; + } + + if (typeof Request !== 'undefined' && input instanceof Request) { + return input.method; + } + + return baseInit?.method ?? 'GET'; +} + +export function getRequestBody( + input: RequestInfo | URL, + init?: RequestInit +): BodyInit | null | undefined { + if (init?.body !== undefined) { + return init.body; + } + + if (typeof Request !== 'undefined' && input instanceof Request) { + return input.body; + } + + return undefined; +} + +export function summarizeRequestBody(body: BodyInit | null | undefined): RequestBodySummary { + if (body == null) { + return { kind: 'empty' }; + } + + if (typeof body === 'string') { + return { kind: 'string', size: body.length }; + } + + if (body instanceof Blob) { + return { kind: 'blob', size: body.size }; + } + + if (body instanceof URLSearchParams) { + return { kind: 'urlsearchparams', size: body.toString().length }; + } + + if (body instanceof FormData) { + return { kind: 'formdata' }; + } + + if (body instanceof ArrayBuffer) { + return { kind: 'arraybuffer', size: body.byteLength }; + } + + if (ArrayBuffer.isView(body)) { + return { kind: body.constructor.name, size: body.byteLength }; + } + + return { kind: typeof body }; +} + +export function formatDiagnosticErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + + return message.includes('Failed to fetch') ? `${message} (check CORS?)` : message; +} + +export function extractJsonRpcMethods(body: BodyInit | null | undefined): string[] | undefined { + if (typeof body !== 'string') { + return undefined; + } + + try { + const parsed = JSON.parse(body); + const messages = Array.isArray(parsed) ? parsed : [parsed]; + const methods = messages + .map((message: Record) => + typeof message?.method === 'string' ? (message.method as string) : undefined + ) + .filter((method: string | undefined): method is string => Boolean(method)); + + return methods.length > 0 ? methods : undefined; + } catch { + return undefined; + } +} diff --git a/tools/ui/src/lib/utils/sanitize.ts b/tools/ui/src/lib/utils/sanitize.ts new file mode 100644 index 000000000..6078ecdf7 --- /dev/null +++ b/tools/ui/src/lib/utils/sanitize.ts @@ -0,0 +1,23 @@ +import { + KEY_VALUE_PAIR_KEY_MAX_LENGTH, + KEY_VALUE_PAIR_VALUE_MAX_LENGTH, + KEY_VALUE_PAIR_UNSAFE_KEY_RE, + KEY_VALUE_PAIR_UNSAFE_VALUE_RE +} from '$lib/constants'; + +/** + * Strip control characters unsafe in identifier/header-name contexts and cap length. + * Removes all C0 controls (including TAB) and DEL. + */ +export function sanitizeKeyValuePairKey(raw: string): string { + return raw.replace(KEY_VALUE_PAIR_UNSAFE_KEY_RE, '').slice(0, KEY_VALUE_PAIR_KEY_MAX_LENGTH); +} + +/** + * Strip control characters that enable header injection; allow TAB; cap length. + * Removes null bytes, CR/LF and other C0/DEL controls while keeping TAB (\x09), + * which is a valid header-value continuation character per RFC 7230. + */ +export function sanitizeKeyValuePairValue(raw: string): string { + return raw.replace(KEY_VALUE_PAIR_UNSAFE_VALUE_RE, '').slice(0, KEY_VALUE_PAIR_VALUE_MAX_LENGTH); +} diff --git a/tools/ui/src/lib/utils/svg-to-png.ts b/tools/ui/src/lib/utils/svg-to-png.ts new file mode 100644 index 000000000..d5a7f7d83 --- /dev/null +++ b/tools/ui/src/lib/utils/svg-to-png.ts @@ -0,0 +1,71 @@ +import { MimeTypeImage } from '$lib/enums'; + +/** + * Convert an SVG base64 data URL to a PNG data URL + * @param base64UrlSvg - The SVG base64 data URL to convert + * @param backgroundColor - Background color for the PNG (default: 'white') + * @returns Promise resolving to PNG data URL + */ +export function svgBase64UrlToPngDataURL( + base64UrlSvg: string, + backgroundColor: string = 'white' +): Promise { + return new Promise((resolve, reject) => { + try { + const img = new Image(); + + img.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('Failed to get 2D canvas context.')); + return; + } + + const targetWidth = img.naturalWidth || 300; + const targetHeight = img.naturalHeight || 300; + + canvas.width = targetWidth; + canvas.height = targetHeight; + + if (backgroundColor) { + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + ctx.drawImage(img, 0, 0, targetWidth, targetHeight); + + resolve(canvas.toDataURL(MimeTypeImage.PNG)); + }; + + img.onerror = () => { + reject(new Error('Failed to load SVG image. Ensure the SVG data is valid.')); + }; + + img.src = base64UrlSvg; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const errorMessage = `Error converting SVG to PNG: ${message}`; + console.error(errorMessage, error); + reject(new Error(errorMessage)); + } + }); +} + +/** + * Check if a file is an SVG based on its MIME type + * @param file - The file to check + * @returns True if the file is an SVG + */ +export function isSvgFile(file: File): boolean { + return file.type === MimeTypeImage.SVG; +} + +/** + * Check if a MIME type represents an SVG + * @param mimeType - The MIME type to check + * @returns True if the MIME type is image/svg+xml + */ +export function isSvgMimeType(mimeType: string): boolean { + return mimeType === MimeTypeImage.SVG; +} diff --git a/tools/ui/src/lib/utils/syntax-highlight-language.ts b/tools/ui/src/lib/utils/syntax-highlight-language.ts new file mode 100644 index 000000000..538429182 --- /dev/null +++ b/tools/ui/src/lib/utils/syntax-highlight-language.ts @@ -0,0 +1,145 @@ +/** + * Maps file extensions to highlight.js language identifiers + */ +export function getLanguageFromFilename(filename: string): string { + const extension = filename.toLowerCase().substring(filename.lastIndexOf('.')); + + switch (extension) { + // JavaScript / TypeScript + case '.js': + case '.mjs': + case '.cjs': + return 'javascript'; + case '.ts': + case '.mts': + case '.cts': + return 'typescript'; + case '.jsx': + return 'javascript'; + case '.tsx': + return 'typescript'; + + // Web + case '.html': + case '.htm': + return 'html'; + case '.css': + return 'css'; + case '.scss': + return 'scss'; + case '.less': + return 'less'; + case '.vue': + return 'html'; + case '.svelte': + return 'html'; + + // Data formats + case '.json': + return 'json'; + case '.xml': + return 'xml'; + case '.yaml': + case '.yml': + return 'yaml'; + case '.toml': + return 'ini'; + case '.csv': + return 'plaintext'; + + // Programming languages + case '.py': + return 'python'; + case '.java': + return 'java'; + case '.kt': + case '.kts': + return 'kotlin'; + case '.scala': + return 'scala'; + case '.cpp': + case '.cc': + case '.cxx': + case '.c++': + return 'cpp'; + case '.c': + return 'c'; + case '.h': + case '.hpp': + return 'cpp'; + case '.cs': + return 'csharp'; + case '.go': + return 'go'; + case '.rs': + return 'rust'; + case '.rb': + return 'ruby'; + case '.php': + return 'php'; + case '.swift': + return 'swift'; + case '.dart': + return 'dart'; + case '.r': + return 'r'; + case '.lua': + return 'lua'; + case '.pl': + case '.pm': + return 'perl'; + + // Shell + case '.sh': + case '.bash': + case '.zsh': + return 'bash'; + case '.bat': + case '.cmd': + return 'dos'; + case '.ps1': + return 'powershell'; + + // Database + case '.sql': + return 'sql'; + + // Markup / Documentation + case '.md': + case '.markdown': + return 'markdown'; + case '.tex': + case '.latex': + return 'latex'; + case '.adoc': + case '.asciidoc': + return 'asciidoc'; + + // Config + case '.ini': + case '.cfg': + case '.conf': + return 'ini'; + case '.dockerfile': + return 'dockerfile'; + case '.nginx': + return 'nginx'; + + // Other + case '.graphql': + case '.gql': + return 'graphql'; + case '.proto': + return 'protobuf'; + case '.diff': + case '.patch': + return 'diff'; + case '.log': + return 'plaintext'; + case '.txt': + return 'plaintext'; + + default: + return 'plaintext'; + } +} diff --git a/tools/ui/src/lib/utils/text-files.ts b/tools/ui/src/lib/utils/text-files.ts new file mode 100644 index 000000000..3f7a55ebc --- /dev/null +++ b/tools/ui/src/lib/utils/text-files.ts @@ -0,0 +1,95 @@ +/** + * Text file processing utilities + * Handles text file detection, reading, and validation + */ + +import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants'; +import type { BinaryDetectionOptions } from '$lib/types'; +import { FileExtensionText } from '$lib/enums'; + +/** + * Check if a filename indicates a text file based on its extension + * @param filename - The filename to check + * @returns True if the filename has a recognized text file extension + */ +export function isTextFileByName(filename: string): boolean { + const textExtensions = Object.values(FileExtensionText); + + return textExtensions.some((ext: FileExtensionText) => filename.toLowerCase().endsWith(ext)); +} + +/** + * Read a file's content as text + * @param file - The file to read + * @returns Promise resolving to the file's text content + */ +export async function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (event) => { + if (event.target?.result !== null && event.target?.result !== undefined) { + resolve(event.target.result as string); + } else { + reject(new Error('Failed to read file')); + } + }; + + reader.onerror = () => reject(new Error('File reading error')); + + reader.readAsText(file); + }); +} + +/** + * Heuristic check to determine if content is likely from a text file + * Detects binary files by counting suspicious characters and null bytes + * @param content - The file content to analyze + * @param options - Optional configuration for detection parameters + * @returns True if the content appears to be text-based + */ +export function isLikelyTextFile( + content: string, + options: Partial = {} +): boolean { + if (!content) return true; + + const config = { ...DEFAULT_BINARY_DETECTION_OPTIONS, ...options }; + const sample = content.substring(0, config.prefixLength); + + let nullCount = 0; + let suspiciousControlCount = 0; + + for (let i = 0; i < sample.length; i++) { + const charCode = sample.charCodeAt(i); + + // Count null bytes - these are strong indicators of binary files + if (charCode === 0) { + nullCount++; + + continue; + } + + // Count suspicious control characters + // Allow common whitespace characters: tab (9), newline (10), carriage return (13) + if (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13) { + // Count most suspicious control characters + if (charCode < 8 || (charCode > 13 && charCode < 27)) { + suspiciousControlCount++; + } + } + + // Count replacement characters (indicates encoding issues) + if (charCode === 0xfffd) { + suspiciousControlCount++; + } + } + + // Reject if too many null bytes + if (nullCount > config.maxAbsoluteNullBytes) return false; + + // Reject if too many suspicious characters + if (suspiciousControlCount / sample.length > config.suspiciousCharThresholdRatio) return false; + + return true; +} diff --git a/tools/ui/src/lib/utils/text.ts b/tools/ui/src/lib/utils/text.ts new file mode 100644 index 000000000..a2a4a1b57 --- /dev/null +++ b/tools/ui/src/lib/utils/text.ts @@ -0,0 +1,22 @@ +import { NEWLINE_SEPARATOR } from '$lib/constants'; + +/** + * Returns a shortened preview of the provided content capped at the given length. + * Appends an ellipsis when the content exceeds the maximum. + */ +export function getPreviewText(content: string, max = 150): string { + return content.length > max ? content.slice(0, max) + '...' : content; +} + +/** + * Generates a single-line title from a potentially multi-line prompt. + * Uses the first non-empty line if `useFirstLine` is true. + */ +export function generateConversationTitle(content: string, useFirstLine: boolean = false): string { + if (useFirstLine) { + const firstLine = content.split(NEWLINE_SEPARATOR).find((line) => line.trim().length > 0); + return firstLine ? firstLine.trim() : content.trim(); + } + + return content.trim(); +} diff --git a/tools/ui/src/lib/utils/uri-template.ts b/tools/ui/src/lib/utils/uri-template.ts new file mode 100644 index 000000000..7665c98c9 --- /dev/null +++ b/tools/ui/src/lib/utils/uri-template.ts @@ -0,0 +1,198 @@ +import { + TEMPLATE_EXPRESSION_REGEX, + URI_SCHEME_SEPARATOR, + URI_TEMPLATE_OPERATORS, + URI_TEMPLATE_SEPARATORS, + VARIABLE_EXPLODE_MODIFIER_REGEX, + VARIABLE_PREFIX_MODIFIER_REGEX, + LEADING_SLASHES_REGEX +} from '../constants'; + +/** + * Normalize a resource URI for comparison. + * + * URI template expansion (especially with path operators like {/var}) + * can produce URIs that differ from listed resource URIs in slash placement. + * For example, the template `svelte://{/slug*}.md` with slug="svelte/$effect" + * expands to `svelte:///svelte/$effect.md`, while the listed resource URI is + * `svelte://svelte/$effect.md`. + * + * This function strips extra leading slashes after the scheme to normalize + * both forms to the same string for comparison purposes. + * + * @param uri - The URI to normalize + * @returns Normalized URI string + */ +export function normalizeResourceUri(uri: string): string { + const schemeEnd = uri.indexOf(URI_SCHEME_SEPARATOR); + if (schemeEnd === -1) return uri; + + const scheme = uri.substring(0, schemeEnd); + const rest = uri + .substring(schemeEnd + URI_SCHEME_SEPARATOR.length) + .replace(LEADING_SLASHES_REGEX, ''); + + return `${scheme}${URI_SCHEME_SEPARATOR}${rest}`; +} + +/** + * A parsed variable from a URI template expression. + */ +export interface UriTemplateVariable { + /** Variable name */ + name: string; + /** Operator prefix (+, #, /, etc.) or empty string */ + operator: string; +} + +/** + * Extract all variable names from a URI template string. + * + * @param template - URI template string (RFC 6570) + * @returns Array of unique variable descriptors + * + * @example + * ```ts + * extractTemplateVariables("file:///{path}") + * // => [{ name: "path", operator: "" }] + * + * extractTemplateVariables("db://{schema}/{table}") + * // => [{ name: "schema", operator: "" }, { name: "table", operator: "" }] + * ``` + */ +export function extractTemplateVariables(template: string): UriTemplateVariable[] { + const variables: UriTemplateVariable[] = []; + const seen = new Set(); + + let match; + TEMPLATE_EXPRESSION_REGEX.lastIndex = 0; + + while ((match = TEMPLATE_EXPRESSION_REGEX.exec(template)) !== null) { + const operator = match[1] || ''; + const varList = match[2]; + + // RFC 6570 allows comma-separated variable lists: {x,y,z} + for (const varSpec of varList.split(',')) { + // Strip explode modifier (*) and prefix modifier (:N) + const name = varSpec + .replace(VARIABLE_EXPLODE_MODIFIER_REGEX, '') + .replace(VARIABLE_PREFIX_MODIFIER_REGEX, '') + .trim(); + + if (name && !seen.has(name)) { + seen.add(name); + variables.push({ name, operator }); + } + } + } + + return variables; +} + +/** + * Expand a URI template with the given variable values. + * Implements a simplified RFC 6570 Level 2 expansion. + * + * @param template - URI template string + * @param values - Map of variable name to value + * @returns Expanded URI string + * + * @example + * ```ts + * expandTemplate("file:///{path}", { path: "src/main.rs" }) + * // => "file:///src/main.rs" + * ``` + */ +export function expandTemplate(template: string, values: Record): string { + TEMPLATE_EXPRESSION_REGEX.lastIndex = 0; + + return template.replace( + TEMPLATE_EXPRESSION_REGEX, + (_match, operator: string, varList: string) => { + const varNames = varList + .split(',') + .map((v: string) => + v + .replace(VARIABLE_EXPLODE_MODIFIER_REGEX, '') + .replace(VARIABLE_PREFIX_MODIFIER_REGEX, '') + .trim() + ); + + const expandedParts = varNames + .map((name: string) => values[name] ?? '') + .filter((v: string) => v !== ''); + + if (expandedParts.length === 0) return ''; + + switch (operator) { + case URI_TEMPLATE_OPERATORS.RESERVED: + // Reserved expansion: no encoding + return expandedParts.join(URI_TEMPLATE_SEPARATORS.COMMA); + case URI_TEMPLATE_OPERATORS.FRAGMENT: + // Fragment expansion + return ( + URI_TEMPLATE_OPERATORS.FRAGMENT + expandedParts.join(URI_TEMPLATE_SEPARATORS.COMMA) + ); + case URI_TEMPLATE_OPERATORS.PATH_SEGMENT: + // Path segments + return URI_TEMPLATE_SEPARATORS.SLASH + expandedParts.join(URI_TEMPLATE_SEPARATORS.SLASH); + case URI_TEMPLATE_OPERATORS.LABEL: + // Label expansion + return ( + URI_TEMPLATE_SEPARATORS.PERIOD + expandedParts.join(URI_TEMPLATE_SEPARATORS.PERIOD) + ); + case URI_TEMPLATE_OPERATORS.PATH_PARAM: + // Path-style parameters + return varNames + .filter((_: string, i: number) => expandedParts[i]) + .map( + (name: string, i: number) => + `${URI_TEMPLATE_SEPARATORS.SEMICOLON}${name}=${expandedParts[i]}` + ) + .join(''); + case URI_TEMPLATE_OPERATORS.FORM_QUERY: + // Form-style query + return ( + URI_TEMPLATE_SEPARATORS.QUERY_PREFIX + + varNames + .filter((_: string, i: number) => expandedParts[i]) + .map( + (name: string, i: number) => + `${encodeURIComponent(name)}=${encodeURIComponent(expandedParts[i])}` + ) + .join(URI_TEMPLATE_SEPARATORS.COMMA) + ); + case URI_TEMPLATE_OPERATORS.FORM_CONTINUATION: + // Form-style query continuation + return ( + URI_TEMPLATE_SEPARATORS.QUERY_CONTINUATION + + varNames + .filter((_: string, i: number) => expandedParts[i]) + .map( + (name: string, i: number) => + `${encodeURIComponent(name)}=${encodeURIComponent(expandedParts[i])}` + ) + .join(URI_TEMPLATE_SEPARATORS.COMMA) + ); + default: + // Simple string expansion (default operator) + return expandedParts + .map((v: string) => encodeURIComponent(v)) + .join(URI_TEMPLATE_SEPARATORS.COMMA); + } + } + ); +} + +/** + * Check whether all required variables in a template have been provided. + * + * @param template - URI template string + * @param values - Map of variable name to value + * @returns true if all variables have non-empty values + */ +export function isTemplateComplete(template: string, values: Record): boolean { + const variables = extractTemplateVariables(template); + + return variables.every((v) => (values[v.name] ?? '').trim() !== ''); +} diff --git a/tools/ui/src/lib/utils/url.ts b/tools/ui/src/lib/utils/url.ts new file mode 100644 index 000000000..e8b78f7bd --- /dev/null +++ b/tools/ui/src/lib/utils/url.ts @@ -0,0 +1,72 @@ +import { TWO_PART_PUBLIC_SUFFIXES, WILDCARD_PUBLIC_SUFFIXES } from '$lib/constants'; +import { UrlProtocol } from '$lib/enums'; + +/** + * Check whether a hostname looks like an IPv4 or IPv6 address. + */ +function isIpAddress(hostname: string): boolean { + if (hostname.includes(':')) return true; + + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return true; + + return false; +} + +/** + * Extract the registrable root domain from a URL. + * + * @example + * 'mcp.example.com' -> 'example.com' + * 'www.example.co.uk' -> 'example.co.uk' + * 'bar.foo.nom.br' -> 'bar.foo.nom.br' + * '192.168.1.1' -> null + * 'localhost' -> null + */ +export function extractRootDomain(url: URL): string | null { + const hostname = url.hostname.toLowerCase(); + if (!hostname || isIpAddress(hostname)) return null; + + const parts = hostname.split('.'); + + if (parts.length < 2) return null; + + if (parts.length >= 3) { + const suffix2 = `${parts[parts.length - 2]}.${parts[parts.length - 1]}`; + + if (TWO_PART_PUBLIC_SUFFIXES.has(suffix2)) { + return parts.slice(-3).join('.'); + } + } + + for (let i = 2; i <= parts.length; i++) { + const candidate = parts.slice(-i).join('.'); + + if (WILDCARD_PUBLIC_SUFFIXES.has(candidate)) { + if (parts.length === i + 1) { + return hostname; + } + + return parts.slice(-(i + 2)).join('.'); + } + } + + return parts.slice(-2).join('.'); +} + +/** + * Sanitize an external URL string for safe use in an ``. + * Only allows http: and https: schemes. Returns `null` for anything else. + */ +export function sanitizeExternalUrl(raw: string): string | null { + try { + const url = new URL(raw); + + if (url.protocol !== UrlProtocol.HTTP && url.protocol !== UrlProtocol.HTTPS) { + return null; + } + + return url.href; + } catch { + return null; + } +} diff --git a/tools/ui/src/lib/utils/uuid.ts b/tools/ui/src/lib/utils/uuid.ts new file mode 100644 index 000000000..29c20b310 --- /dev/null +++ b/tools/ui/src/lib/utils/uuid.ts @@ -0,0 +1,3 @@ +export function uuid(): string { + return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).substring(2); +} diff --git a/tools/ui/src/lib/utils/viewport.ts b/tools/ui/src/lib/utils/viewport.ts new file mode 100644 index 000000000..9e9b7aff3 --- /dev/null +++ b/tools/ui/src/lib/utils/viewport.ts @@ -0,0 +1,12 @@ +/** + * Check if an element is within the current viewport. + */ +export function isElementInViewport(node: HTMLElement): boolean { + const rect = node.getBoundingClientRect(); + return ( + rect.top < window.innerHeight && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.right > 0 + ); +} diff --git a/tools/ui/src/lib/utils/webp-to-png.ts b/tools/ui/src/lib/utils/webp-to-png.ts new file mode 100644 index 000000000..ea5183802 --- /dev/null +++ b/tools/ui/src/lib/utils/webp-to-png.ts @@ -0,0 +1,73 @@ +import { FileExtensionImage, MimeTypeImage } from '$lib/enums'; + +/** + * Convert a WebP base64 data URL to a PNG data URL + * @param base64UrlWebp - The WebP base64 data URL to convert + * @param backgroundColor - Background color for the PNG (default: 'white') + * @returns Promise resolving to PNG data URL + */ +export function webpBase64UrlToPngDataURL( + base64UrlWebp: string, + backgroundColor: string = 'white' +): Promise { + return new Promise((resolve, reject) => { + try { + const img = new Image(); + + img.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('Failed to get 2D canvas context.')); + return; + } + + const targetWidth = img.naturalWidth || 300; + const targetHeight = img.naturalHeight || 300; + + canvas.width = targetWidth; + canvas.height = targetHeight; + + if (backgroundColor) { + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + ctx.drawImage(img, 0, 0, targetWidth, targetHeight); + + resolve(canvas.toDataURL(MimeTypeImage.PNG)); + }; + + img.onerror = () => { + reject(new Error('Failed to load WebP image. Ensure the WebP data is valid.')); + }; + + img.src = base64UrlWebp; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const errorMessage = `Error converting WebP to PNG: ${message}`; + console.error(errorMessage, error); + reject(new Error(errorMessage)); + } + }); +} + +/** + * Check if a file is a WebP based on its MIME type + * @param file - The file to check + * @returns True if the file is a WebP + */ +export function isWebpFile(file: File): boolean { + return ( + file.type === MimeTypeImage.WEBP || file.name.toLowerCase().endsWith(FileExtensionImage.WEBP) + ); +} + +/** + * Check if a MIME type represents a WebP + * @param mimeType - The MIME type to check + * @returns True if the MIME type is image/webp + */ +export function isWebpMimeType(mimeType: string): boolean { + return mimeType === MimeTypeImage.WEBP; +} diff --git a/tools/ui/src/routes/(chat)/+layout.svelte b/tools/ui/src/routes/(chat)/+layout.svelte new file mode 100644 index 000000000..37aa03582 --- /dev/null +++ b/tools/ui/src/routes/(chat)/+layout.svelte @@ -0,0 +1,12 @@ + + + + +{@render children?.()} diff --git a/tools/ui/src/routes/(chat)/+page.svelte b/tools/ui/src/routes/(chat)/+page.svelte new file mode 100644 index 000000000..c272b438e --- /dev/null +++ b/tools/ui/src/routes/(chat)/+page.svelte @@ -0,0 +1,103 @@ + + + + {APP_NAME} + + + diff --git a/tools/ui/src/routes/(chat)/+page.ts b/tools/ui/src/routes/(chat)/+page.ts new file mode 100644 index 000000000..7905af6b5 --- /dev/null +++ b/tools/ui/src/routes/(chat)/+page.ts @@ -0,0 +1,6 @@ +import type { PageLoad } from './$types'; +import { validateApiKey } from '$lib/utils'; + +export const load: PageLoad = async ({ fetch }) => { + await validateApiKey(fetch); +}; diff --git a/tools/ui/src/routes/(chat)/chat/[id]/+page.svelte b/tools/ui/src/routes/(chat)/chat/[id]/+page.svelte new file mode 100644 index 000000000..e31d4443e --- /dev/null +++ b/tools/ui/src/routes/(chat)/chat/[id]/+page.svelte @@ -0,0 +1,135 @@ + + + + {activeConversation()?.name || 'Chat'} - {APP_NAME} + + + diff --git a/tools/ui/src/routes/(chat)/chat/[id]/+page.ts b/tools/ui/src/routes/(chat)/chat/[id]/+page.ts new file mode 100644 index 000000000..7905af6b5 --- /dev/null +++ b/tools/ui/src/routes/(chat)/chat/[id]/+page.ts @@ -0,0 +1,6 @@ +import type { PageLoad } from './$types'; +import { validateApiKey } from '$lib/utils'; + +export const load: PageLoad = async ({ fetch }) => { + await validateApiKey(fetch); +}; diff --git a/tools/ui/src/routes/+error.svelte b/tools/ui/src/routes/+error.svelte new file mode 100644 index 000000000..8da9aad16 --- /dev/null +++ b/tools/ui/src/routes/+error.svelte @@ -0,0 +1,72 @@ + + + + Error {status} - {APP_NAME} + + +{#if isApiKeyError} + +{:else} + +
                +
                +
                +
                + + + +
                +

                Error {status}

                +

                + {error?.message || 'Something went wrong'} +

                +
                + +
                +
                +{/if} diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte new file mode 100644 index 000000000..ce0014992 --- /dev/null +++ b/tools/ui/src/routes/+layout.svelte @@ -0,0 +1,269 @@ + + + + + + + + + + +
                + + + + + {#if !(alwaysShowSidebarOnDesktop && isDesktop) && !(panelNav.isSettingsRoute && !isDesktop)} + {#if mounted} +
                + +
                + {/if} + {/if} + + {#if isDesktop && !alwaysShowSidebarOnDesktop} + { + if (chatSidebar?.activateSearchMode) { + chatSidebar.activateSearchMode(); + } + + sidebarOpen = true; + }} + /> + {/if} + + + {@render children?.()} + +
                +
                +
                + + diff --git a/tools/ui/src/routes/mcp-servers/+page.svelte b/tools/ui/src/routes/mcp-servers/+page.svelte new file mode 100644 index 000000000..1758134c3 --- /dev/null +++ b/tools/ui/src/routes/mcp-servers/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/tools/ui/src/routes/settings/+layout.svelte b/tools/ui/src/routes/settings/+layout.svelte new file mode 100644 index 000000000..b3b3d30e0 --- /dev/null +++ b/tools/ui/src/routes/settings/+layout.svelte @@ -0,0 +1,38 @@ + + +
                +
                + +
                + +
                + {@render children?.()} +
                +
                diff --git a/tools/ui/src/routes/settings/[[section]]/+page.svelte b/tools/ui/src/routes/settings/[[section]]/+page.svelte new file mode 100644 index 000000000..22e727f17 --- /dev/null +++ b/tools/ui/src/routes/settings/[[section]]/+page.svelte @@ -0,0 +1,16 @@ + + +).section} /> diff --git a/tools/ui/src/styles/katex-custom.scss b/tools/ui/src/styles/katex-custom.scss new file mode 100644 index 000000000..9c8b96ed5 --- /dev/null +++ b/tools/ui/src/styles/katex-custom.scss @@ -0,0 +1,13 @@ +// Override KaTeX SCSS variables to disable ttf and woff fonts +// Only use woff2 format which is embedded in the bundle +$use-woff2: true; +$use-woff: false; +$use-ttf: false; + +// Use Vite alias for font folder +$font-folder: 'katex-fonts'; + +// Import KaTeX SCSS with overridden variables +// Note: @import is deprecated but required because KaTeX uses @import internally +// The deprecation warnings are from KaTeX's code and cannot be avoided +@import 'katex/src/styles/katex.scss'; diff --git a/tools/ui/static/favicon.svg b/tools/ui/static/favicon.svg new file mode 100644 index 000000000..a7ae13691 --- /dev/null +++ b/tools/ui/static/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tools/ui/static/loading.html b/tools/ui/static/loading.html new file mode 100644 index 000000000..c3fd19a0f --- /dev/null +++ b/tools/ui/static/loading.html @@ -0,0 +1,12 @@ + + + + + + +
                + The model is loading. Please wait.
                + The user interface will appear soon. +
                + + diff --git a/tools/ui/svelte.config.js b/tools/ui/svelte.config.js new file mode 100644 index 000000000..4b14065da --- /dev/null +++ b/tools/ui/svelte.config.js @@ -0,0 +1,37 @@ +import { mdsvex } from 'mdsvex'; +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: [vitePreprocess(), mdsvex()], + + kit: { + paths: { + relative: true + }, + router: { type: 'hash' }, + adapter: adapter({ + pages: '../../build/tools/ui/dist', + assets: '../../build/tools/ui/dist', + fallback: 'index.html', + precompress: false, + strict: true + }), + output: { + bundleStrategy: 'single' + }, + alias: { + $styles: 'src/styles' + }, + version: { + name: 'llama-ui' + } + }, + + extensions: ['.svelte', '.svx'] +}; + +export default config; diff --git a/tools/ui/tests/client/components/TestWrapper.svelte b/tools/ui/tests/client/components/TestWrapper.svelte new file mode 100644 index 000000000..aeb7ff74c --- /dev/null +++ b/tools/ui/tests/client/components/TestWrapper.svelte @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/tools/ui/tests/client/page.svelte.test.ts b/tools/ui/tests/client/page.svelte.test.ts new file mode 100644 index 000000000..6849beb27 --- /dev/null +++ b/tools/ui/tests/client/page.svelte.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import TestWrapper from './components/TestWrapper.svelte'; + +describe('/+page.svelte', () => { + it('should render page without throwing', async () => { + // Basic smoke test - page should render without throwing errors + // API calls will fail in test environment but component should still mount + expect(() => render(TestWrapper)).not.toThrow(); + }); +}); diff --git a/tools/ui/tests/e2e/demo.test.ts b/tools/ui/tests/e2e/demo.test.ts new file mode 100644 index 000000000..b7b4bac33 --- /dev/null +++ b/tools/ui/tests/e2e/demo.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from '@playwright/test'; + +test('home page has expected h1', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('h1').first()).toBeVisible(); +}); diff --git a/tools/ui/tests/stories/ChatMessage.stories.svelte b/tools/ui/tests/stories/ChatMessage.stories.svelte new file mode 100644 index 000000000..e640176a8 --- /dev/null +++ b/tools/ui/tests/stories/ChatMessage.stories.svelte @@ -0,0 +1,207 @@ + + + { + const { settingsStore } = await import('$lib/stores/settings.svelte'); + settingsStore.updateConfig('showRawOutputSwitch', false); + }} +/> + + { + const { settingsStore } = await import('$lib/stores/settings.svelte'); + settingsStore.updateConfig('showRawOutputSwitch', false); + }} +/> + + { + const { settingsStore } = await import('$lib/stores/settings.svelte'); + settingsStore.updateConfig('showRawOutputSwitch', false); + }} +/> + + { + const { settingsStore } = await import('$lib/stores/settings.svelte'); + settingsStore.updateConfig('showRawOutputSwitch', true); + }} +/> + + { + const { settingsStore } = await import('$lib/stores/settings.svelte'); + settingsStore.updateConfig('showRawOutputSwitch', false); + // Phase 1: Stream reasoning content in chunks + let reasoningText = + 'I need to think about this carefully. Let me break down the problem:\n\n1. The user is asking for help with something complex\n2. I should provide a thorough and helpful response\n3. I need to consider multiple approaches\n4. The best solution would be to explain step by step\n\nThis approach will ensure clarity and understanding.'; + + let reasoningChunk = 'I'; + let i = 0; + while (i < reasoningText.length) { + const chunkSize = Math.floor(Math.random() * 5) + 3; // Random 3-7 characters + const chunk = reasoningText.slice(i, i + chunkSize); + reasoningChunk += chunk; + + // Update the reactive state directly + streamingMessage.thinking = reasoningChunk; + + i += chunkSize; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + const regularText = + "Based on my analysis, here's the solution:\n\n**Step 1:** First, we need to understand the requirements clearly.\n\n**Step 2:** Then we can implement the solution systematically.\n\n**Step 3:** Finally, we test and validate the results.\n\nThis approach ensures we cover all aspects of the problem effectively."; + + let contentChunk = ''; + i = 0; + + while (i < regularText.length) { + const chunkSize = Math.floor(Math.random() * 5) + 3; // Random 3-7 characters + const chunk = regularText.slice(i, i + chunkSize); + contentChunk += chunk; + + // Update the reactive state directly + streamingMessage.content = contentChunk; + + i += chunkSize; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + streamingMessage.timestamp = Date.now(); + }} +> +
                + +
                +
                + + { + const { settingsStore } = await import('$lib/stores/settings.svelte'); + settingsStore.updateConfig('showRawOutputSwitch', false); + // Import the chat store to simulate loading state + const { chatStore } = await import('$lib/stores/chat.svelte'); + + // Set loading state to true to trigger the processing UI + chatStore.isLoading = true; + + // Simulate the processing state hook behavior + // This will show the "Generating..." text and parameter details + await new Promise((resolve) => setTimeout(resolve, 100)); + }} +/> diff --git a/tools/ui/tests/stories/ChatScreenForm.stories.svelte b/tools/ui/tests/stories/ChatScreenForm.stories.svelte new file mode 100644 index 000000000..4c1734345 --- /dev/null +++ b/tools/ui/tests/stories/ChatScreenForm.stories.svelte @@ -0,0 +1,94 @@ + + + { + const textarea = await canvas.findByRole('textbox'); + const submitButton = await canvas.findByRole('button', { name: 'Send' }); + + // Expect the input to be focused after the component is mounted + await expect(textarea).toHaveFocus(); + + // Expect the submit button to be disabled + await expect(submitButton).toBeDisabled(); + + const text = 'What is the meaning of life?'; + + await userEvent.clear(textarea); + await userEvent.type(textarea, text); + + await expect(textarea).toHaveValue(text); + + const fileInput = document.querySelector('input[type="file"]'); + await expect(fileInput).not.toHaveAttribute('accept'); + }} +/> + + + + { + const jpgAttachment = canvas.getByAltText('1.jpg'); + const svgAttachment = canvas.getByAltText('hf-logo.svg'); + const pdfFileExtension = canvas.getByText('PDF'); + const pdfAttachment = canvas.getByText('example.pdf'); + const pdfSize = canvas.getByText('342.82 KB'); + + await expect(jpgAttachment).toBeInTheDocument(); + await expect(jpgAttachment).toHaveAttribute('src', jpgAsset); + + await expect(svgAttachment).toBeInTheDocument(); + await expect(svgAttachment).toHaveAttribute('src', svgAsset); + + await expect(pdfFileExtension).toBeInTheDocument(); + await expect(pdfAttachment).toBeInTheDocument(); + await expect(pdfSize).toBeInTheDocument(); + }} +/> diff --git a/tools/ui/tests/stories/Introduction.mdx b/tools/ui/tests/stories/Introduction.mdx new file mode 100644 index 000000000..d4c0c7921 --- /dev/null +++ b/tools/ui/tests/stories/Introduction.mdx @@ -0,0 +1,44 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# llama.cpp Web UI + +Welcome to the **llama-ui** component library! This Storybook showcases the components used in the modern web interface for the llama-server. + +## 🚀 About This Project + +Llama UI is a modern web interface for the llama-server, built with SvelteKit and ShadCN UI. Features include: + +- **Real-time chat conversations** with AI assistants +- **Multi-conversation management** with persistent storage +- **Advanced parameter tuning** for model behavior +- **File upload support** for multimodal interactions +- **Responsive design** that works on desktop and mobile + +## 🎨 Design System + +The UI is built using: + +- **SvelteKit** - Modern web framework with excellent performance +- **Tailwind CSS** - Utility-first CSS framework for rapid styling +- **ShadCN/UI** - High-quality, accessible component library +- **Lucide Icons** - Beautiful, consistent icon set + +## 🔧 Development + +This Storybook serves as both documentation and a development environment for the UI components. Each story demonstrates: + +- **Component variations** - Different states and configurations +- **Interactive examples** - Live components you can interact with +- **Usage patterns** - How components work together +- **Styling consistency** - Unified design language + +## 🚀 Getting Started + +To explore the components: + +1. **Browse the sidebar** to see all available components +2. **Click on stories** to see different component states +3. **Use the controls panel** to interact with component props +4. **Check the docs tab** for detailed component information diff --git a/tools/ui/tests/stories/MarkdownContent.stories.svelte b/tools/ui/tests/stories/MarkdownContent.stories.svelte new file mode 100644 index 000000000..04f270a43 --- /dev/null +++ b/tools/ui/tests/stories/MarkdownContent.stories.svelte @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + { + const { canvasElement } = context; + // Wait for component to render + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Find all links in the rendered content + const links = (canvasElement as HTMLElement).querySelectorAll( + 'a[href]' + ) as NodeListOf; + const linkList = Array.from(links) as HTMLAnchorElement[]; + + // Test that we have the expected number of links + expect(links.length).toBeGreaterThan(0); + + // Test each link for proper attributes + links.forEach((link: HTMLAnchorElement) => { + const href = link.getAttribute('href'); + + // Test that external links have proper security attributes + if (href && (href.startsWith('http://') || href.startsWith('https://'))) { + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + } + }); + + // Test specific links exist + const hugginFaceLink = linkList.find( + (link) => link.getAttribute('href') === 'https://huggingface.co' + ); + expect(hugginFaceLink).toBeTruthy(); + expect(hugginFaceLink?.textContent).toBe('Hugging Face Homepage'); + + const githubLink = linkList.find( + (link) => link.getAttribute('href') === 'https://github.com/ggml-org/llama.cpp' + ); + expect(githubLink).toBeTruthy(); + expect(githubLink?.textContent).toBe('GitHub Repository'); + + const openaiLink = linkList.find((link) => link.getAttribute('href') === 'https://openai.com'); + expect(openaiLink).toBeTruthy(); + expect(openaiLink?.textContent).toBe('OpenAI Website'); + + const googleLink = linkList.find( + (link) => link.getAttribute('href') === 'https://www.google.com' + ); + expect(googleLink).toBeTruthy(); + expect(googleLink?.textContent).toBe('Google Search'); + + // Test inline links (auto-linked URLs) + const exampleLink = linkList.find( + (link) => link.getAttribute('href') === 'https://example.com' + ); + expect(exampleLink).toBeTruthy(); + + const pythonDocsLink = linkList.find( + (link) => link.getAttribute('href') === 'https://docs.python.org' + ); + expect(pythonDocsLink).toBeTruthy(); + + console.log(`✅ URL Links test passed - Found ${links.length} links with proper attributes`); + }} +/> diff --git a/tools/ui/tests/stories/SidebarNavigation.stories.svelte b/tools/ui/tests/stories/SidebarNavigation.stories.svelte new file mode 100644 index 000000000..f64ee4f9b --- /dev/null +++ b/tools/ui/tests/stories/SidebarNavigation.stories.svelte @@ -0,0 +1,109 @@ + + + + + { + const { conversationsStore } = await import('$lib/stores/conversations.svelte'); + + waitFor(() => setTimeout(() => { + conversationsStore.conversations = mockConversations; + }, 0)); + }} +> + +
                + +
                +
                +
                + + { + const { conversationsStore } = await import('$lib/stores/conversations.svelte'); + + waitFor(() => setTimeout(() => { + conversationsStore.conversations = mockConversations; + }, 0)); + + const searchTrigger = screen.getByText('Search'); + userEvent.click(searchTrigger); + }} +> + +
                + +
                +
                +
                + + { + // Mock empty conversations store + const { conversationsStore } = await import('$lib/stores/conversations.svelte'); + conversationsStore.conversations = []; + }} +> + +
                + +
                +
                +
                diff --git a/tools/ui/tests/stories/fixtures/ai-tutorial.ts b/tools/ui/tests/stories/fixtures/ai-tutorial.ts new file mode 100644 index 000000000..b3b1c2483 --- /dev/null +++ b/tools/ui/tests/stories/fixtures/ai-tutorial.ts @@ -0,0 +1,164 @@ +// AI Assistant Tutorial Response +export const AI_TUTORIAL_MD = String.raw` +# Building a Modern Chat Application with SvelteKit + +I'll help you create a **production-ready chat application** using SvelteKit, TypeScript, and WebSockets. This implementation includes real-time messaging, user authentication, and message persistence. + +## 🚀 Quick Start + +First, let's set up the project: + +${'```'}bash +npm create svelte@latest chat-app +cd chat-app +npm install +npm install socket.io socket.io-client +npm install @prisma/client prisma +npm run dev +${'```'} + +## 📁 Project Structure + +${'```'} +chat-app/ +├── src/ +│ ├── routes/ +│ │ ├── +layout.svelte +│ │ ├── +page.svelte +│ │ └── api/ +│ │ └── socket/+server.ts +│ ├── lib/ +│ │ ├── components/ +│ │ │ ├── ChatMessage.svelte +│ │ │ └── ChatInput.svelte +│ │ └── stores/ +│ │ └── chat.ts +│ └── app.html +├── prisma/ +│ └── schema.prisma +└── package.json +${'```'} + +## 💻 Implementation + +### WebSocket Server + +${'```'}typescript +// src/lib/server/socket.ts +import { Server } from 'socket.io'; +import type { ViteDevServer } from 'vite'; + +export function initializeSocketIO(server: ViteDevServer) { + const io = new Server(server.httpServer || server, { + cors: { + origin: process.env.ORIGIN || 'http://localhost:5173', + credentials: true + } + }); + + io.on('connection', (socket) => { + console.log('User connected:', socket.id); + + socket.on('message', async (data) => { + // Broadcast to all clients + io.emit('new-message', { + id: crypto.randomUUID(), + userId: socket.id, + content: data.content, + timestamp: new Date().toISOString() + }); + }); + + socket.on('disconnect', () => { + console.log('User disconnected:', socket.id); + }); + }); + + return io; +} +${'```'} + +### Client Store + +${'```'}typescript +// src/lib/stores/chat.ts +import { writable } from 'svelte/store'; +import io from 'socket.io-client'; + +export interface Message { + id: string; + userId: string; + content: string; + timestamp: string; +} + +function createChatStore() { + const { subscribe, update } = writable([]); + let socket: ReturnType; + + return { + subscribe, + connect: () => { + socket = io('http://localhost:5173'); + + socket.on('new-message', (message: Message) => { + update(messages => [...messages, message]); + }); + }, + sendMessage: (content: string) => { + if (socket && content.trim()) { + socket.emit('message', { content }); + } + } + }; +} + +export const chatStore = createChatStore(); +${'```'} + +## 🎯 Key Features + +✅ **Real-time messaging** with WebSockets +✅ **Message persistence** using Prisma + PostgreSQL +✅ **Type-safe** with TypeScript +✅ **Responsive UI** for all devices +✅ **Auto-reconnection** on connection loss + +## 📊 Performance Metrics + +| Metric | Value | +|--------|-------| +| **Message Latency** | < 50ms | +| **Concurrent Users** | 10,000+ | +| **Messages/Second** | 5,000+ | +| **Uptime** | 99.9% | + +## 🔧 Configuration + +### Environment Variables + +${'```'}env +DATABASE_URL="postgresql://user:password@localhost:5432/chat" +JWT_SECRET="your-secret-key" +REDIS_URL="redis://localhost:6379" +${'```'} + +## 🚢 Deployment + +Deploy to production using Docker: + +${'```'}dockerfile +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production +COPY . . +RUN npm run build +EXPOSE 3000 +CMD ["node", "build"] +${'```'} + +--- + +*Need help? Check the [documentation](https://kit.svelte.dev) or [open an issue](https://github.com/sveltejs/kit/issues)* +`; diff --git a/tools/ui/tests/stories/fixtures/api-docs.ts b/tools/ui/tests/stories/fixtures/api-docs.ts new file mode 100644 index 000000000..7b499956f --- /dev/null +++ b/tools/ui/tests/stories/fixtures/api-docs.ts @@ -0,0 +1,160 @@ +// API Documentation +export const API_DOCS_MD = String.raw` +# REST API Documentation + +## 🔐 Authentication + +All API requests require authentication using **Bearer tokens**. Include your API key in the Authorization header: + +${'```'}http +GET /api/v1/users +Host: api.example.com +Authorization: Bearer YOUR_API_KEY +Content-Type: application/json +${'```'} + +## 📍 Endpoints + +### Users API + +#### **GET** /api/v1/users + +Retrieve a paginated list of users. + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| page | integer | 1 | Page number | +| limit | integer | 20 | Items per page | +| sort | string | "created_at" | Sort field | +| order | string | "desc" | Sort order | + +**Response:** 200 OK + +${'```'}json +{ + "data": [ + { + "id": "usr_1234567890", + "email": "user@example.com", + "name": "John Doe", + "role": "admin", + "created_at": "2024-01-15T10:30:00Z" + } + ], + "pagination": { + "page": 1, + "limit": 20, + "total": 156, + "pages": 8 + } +} +${'```'} + +#### **POST** /api/v1/users + +Create a new user account. + +**Request Body:** + +${'```'}json +{ + "email": "newuser@example.com", + "password": "SecurePassword123!", + "name": "Jane Smith", + "role": "user" +} +${'```'} + +**Response:** 201 Created + +${'```'}json +{ + "id": "usr_9876543210", + "email": "newuser@example.com", + "name": "Jane Smith", + "role": "user", + "created_at": "2024-01-21T09:15:00Z" +} +${'```'} + +### Error Responses + +The API returns errors in a consistent format: + +${'```'}json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid request parameters", + "details": [ + { + "field": "email", + "message": "Email format is invalid" + } + ] + } +} +${'```'} + +### Rate Limiting + +| Tier | Requests/Hour | Burst | +|------|--------------|-------| +| **Free** | 1,000 | 100 | +| **Pro** | 10,000 | 500 | +| **Enterprise** | Unlimited | - | + +**Headers:** +- X-RateLimit-Limit +- X-RateLimit-Remaining +- X-RateLimit-Reset + +### Webhooks + +Configure webhooks to receive real-time events: + +${'```'}javascript +// Webhook payload +{ + "event": "user.created", + "timestamp": "2024-01-21T09:15:00Z", + "data": { + "id": "usr_9876543210", + "email": "newuser@example.com" + }, + "signature": "sha256=abcd1234..." +} +${'```'} + +### SDK Examples + +**JavaScript/TypeScript:** + +${'```'}typescript +import { ApiClient } from '@example/api-sdk'; + +const client = new ApiClient({ + apiKey: process.env.API_KEY +}); + +const users = await client.users.list({ + page: 1, + limit: 20 +}); +${'```'} + +**Python:** + +${'```'}python +from example_api import Client + +client = Client(api_key=os.environ['API_KEY']) +users = client.users.list(page=1, limit=20) +${'```'} + +--- + +📚 [Full API Reference](https://api.example.com/docs) | 💬 [Support](https://support.example.com) +`; diff --git a/tools/ui/tests/stories/fixtures/assets/1.jpg b/tools/ui/tests/stories/fixtures/assets/1.jpg new file mode 100644 index 000000000..8348e3878 Binary files /dev/null and b/tools/ui/tests/stories/fixtures/assets/1.jpg differ diff --git a/tools/ui/tests/stories/fixtures/assets/beautiful-flowers-lotus.webp b/tools/ui/tests/stories/fixtures/assets/beautiful-flowers-lotus.webp new file mode 100644 index 000000000..6efcffc3b Binary files /dev/null and b/tools/ui/tests/stories/fixtures/assets/beautiful-flowers-lotus.webp differ diff --git a/tools/ui/tests/stories/fixtures/assets/example.pdf b/tools/ui/tests/stories/fixtures/assets/example.pdf new file mode 100644 index 000000000..915d30150 Binary files /dev/null and b/tools/ui/tests/stories/fixtures/assets/example.pdf differ diff --git a/tools/ui/tests/stories/fixtures/assets/hf-logo.svg b/tools/ui/tests/stories/fixtures/assets/hf-logo.svg new file mode 100644 index 000000000..d55ea22a2 --- /dev/null +++ b/tools/ui/tests/stories/fixtures/assets/hf-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/tools/ui/tests/stories/fixtures/blog-post.ts b/tools/ui/tests/stories/fixtures/blog-post.ts new file mode 100644 index 000000000..3eb2ed758 --- /dev/null +++ b/tools/ui/tests/stories/fixtures/blog-post.ts @@ -0,0 +1,125 @@ +// Blog Post Content +export const BLOG_POST_MD = String.raw` +# Understanding Rust's Ownership System + +*Published on March 15, 2024 • 8 min read* + +Rust's ownership system is one of its most distinctive features, enabling memory safety without garbage collection. In this post, we'll explore how ownership works and why it's revolutionary for systems programming. + +## What is Ownership? + +Ownership is a set of rules that governs how Rust manages memory. These rules are checked at compile time, ensuring memory safety without runtime overhead. + +### The Three Rules of Ownership + +1. **Each value has a single owner** +2. **There can only be one owner at a time** +3. **When the owner goes out of scope, the value is dropped** + +## Memory Management Without GC + +Traditional approaches to memory management: + +- **Manual management** (C/C++): Error-prone, leads to bugs +- **Garbage collection** (Java, Python): Runtime overhead +- **Ownership** (Rust): Compile-time safety, zero runtime cost + +## Basic Examples + +### Variable Scope + +${'```'}rust +fn main() { + let s = String::from("hello"); // s comes into scope + + // s is valid here + println!("{}", s); + +} // s goes out of scope and is dropped +${'```'} + +### Move Semantics + +${'```'}rust +fn main() { + let s1 = String::from("hello"); + let s2 = s1; // s1 is moved to s2 + + // println!("{}", s1); // ❌ ERROR: s1 is no longer valid + println!("{}", s2); // ✅ OK: s2 owns the string +} +${'```'} + +## Borrowing and References + +Instead of transferring ownership, you can **borrow** values: + +### Immutable References + +${'```'}rust +fn calculate_length(s: &String) -> usize { + s.len() // s is a reference, doesn't own the String +} + +fn main() { + let s1 = String::from("hello"); + let len = calculate_length(&s1); // Borrow s1 + println!("Length of '{}' is {}", s1, len); // s1 still valid +} +${'```'} + +### Mutable References + +${'```'}rust +fn main() { + let mut s = String::from("hello"); + + let r1 = &mut s; + r1.push_str(", world"); + println!("{}", r1); + + // let r2 = &mut s; // ❌ ERROR: cannot borrow twice +} +${'```'} + +## Common Pitfalls + +### Dangling References + +${'```'}rust +fn dangle() -> &String { // ❌ ERROR: missing lifetime specifier + let s = String::from("hello"); + &s // s will be dropped, leaving a dangling reference +} +${'```'} + +### ✅ Solution + +${'```'}rust +fn no_dangle() -> String { + let s = String::from("hello"); + s // Ownership is moved out +} +${'```'} + +## Benefits + +- ✅ **No null pointer dereferences** +- ✅ **No data races** +- ✅ **No use-after-free** +- ✅ **No memory leaks** + +## Conclusion + +Rust's ownership system eliminates entire classes of bugs at compile time. While it has a learning curve, the benefits in safety and performance are worth it. + +## Further Reading + +- [The Rust Book - Ownership](https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html) +- [Rust by Example - Ownership](https://doc.rust-lang.org/rust-by-example/scope/move.html) +- [Rustlings Exercises](https://github.com/rust-lang/rustlings) + +--- + +*Questions? Reach out on [Twitter](https://twitter.com/rustlang) or join the [Rust Discord](https://discord.gg/rust-lang)* +`; diff --git a/tools/ui/tests/stories/fixtures/data-analysis.ts b/tools/ui/tests/stories/fixtures/data-analysis.ts new file mode 100644 index 000000000..6fec32dad --- /dev/null +++ b/tools/ui/tests/stories/fixtures/data-analysis.ts @@ -0,0 +1,124 @@ +// Data Analysis Report +export const DATA_ANALYSIS_MD = String.raw` +# Q4 2024 Business Analytics Report + +*Executive Summary • Generated on January 15, 2025* + +## 📊 Key Performance Indicators + +${'```'} +Daily Active Users (DAU): 1.2M (+65% YoY) +Monthly Active Users (MAU): 4.5M (+48% YoY) +User Retention (Day 30): 68% (+12pp YoY) +Average Session Duration: 24min (+35% YoY) +${'```'} + +## 🎯 Product Performance + +### Feature Adoption Rates + +1. **AI Assistant**: 78% of users (↑ from 45%) +2. **Collaboration Tools**: 62% of users (↑ from 38%) +3. **Analytics Dashboard**: 54% of users (↑ from 31%) +4. **Mobile App**: 41% of users (↑ from 22%) + +### Customer Satisfaction + +| Metric | Q4 2024 | Q3 2024 | Change | +|--------|---------|---------|--------| +| **NPS Score** | 72 | 68 | +4 | +| **CSAT** | 4.6/5 | 4.4/5 | +0.2 | +| **Support Tickets** | 2,340 | 2,890 | -19% | +| **Resolution Time** | 4.2h | 5.1h | -18% | + +## 💰 Revenue Metrics + +### Monthly Recurring Revenue (MRR) + +- **Current MRR**: $2.8M (+42% YoY) +- **New MRR**: $340K +- **Expansion MRR**: $180K +- **Churned MRR**: $95K +- **Net New MRR**: $425K + +### Customer Acquisition + +${'```'} +Cost per Acquisition (CAC): $127 (-23% YoY) +Customer Lifetime Value: $1,840 (+31% YoY) +LTV:CAC Ratio: 14.5:1 +Payback Period: 3.2 months +${'```'} + +## 🌍 Geographic Performance + +### Revenue by Region + +1. **North America**: 45% ($1.26M) +2. **Europe**: 32% ($896K) +3. **Asia-Pacific**: 18% ($504K) +4. **Other**: 5% ($140K) + +### Growth Opportunities + +- **APAC**: 89% YoY growth potential +- **Latin America**: Emerging market entry +- **Middle East**: Enterprise expansion + +## 📱 Channel Performance + +### Traffic Sources + +| Channel | Sessions | Conversion | Revenue | +|---------|----------|------------|---------| +| **Organic Search** | 45% | 3.2% | $1.1M | +| **Direct** | 28% | 4.1% | $850K | +| **Social Media** | 15% | 2.8% | $420K | +| **Paid Ads** | 12% | 5.5% | $430K | + +### Marketing ROI + +- **Content Marketing**: 340% ROI +- **Email Campaigns**: 280% ROI +- **Social Media**: 190% ROI +- **Paid Search**: 220% ROI + +## 🔍 User Behavior Analysis + +### Session Patterns + +- **Peak Hours**: 9-11 AM, 2-4 PM EST +- **Mobile Usage**: 67% of sessions +- **Average Pages/Session**: 4.8 +- **Bounce Rate**: 23% (↓ from 31%) + +### Feature Usage Heatmap + +Most used features in order: +1. Dashboard (89% of users) +2. Search (76% of users) +3. Reports (64% of users) +4. Settings (45% of users) +5. Integrations (32% of users) + +## 💡 Recommendations + +1. **Invest** in AI capabilities (+$2M budget) +2. **Expand** sales team in APAC region +3. **Improve** onboarding to reduce churn +4. **Launch** enterprise security features + +## Appendix + +### Methodology + +Data collected from: +- Internal analytics (Amplitude) +- Customer surveys (n=2,450) +- Financial systems (NetSuite) +- Market research (Gartner) + +--- + +*Report prepared by Data Analytics Team • [View Interactive Dashboard](https://analytics.example.com)* +`; diff --git a/tools/ui/tests/stories/fixtures/empty.ts b/tools/ui/tests/stories/fixtures/empty.ts new file mode 100644 index 000000000..05286e7a7 --- /dev/null +++ b/tools/ui/tests/stories/fixtures/empty.ts @@ -0,0 +1,2 @@ +// Empty state +export const EMPTY_MD = ''; diff --git a/tools/ui/tests/stories/fixtures/math-formulas.ts b/tools/ui/tests/stories/fixtures/math-formulas.ts new file mode 100644 index 000000000..1355256b2 --- /dev/null +++ b/tools/ui/tests/stories/fixtures/math-formulas.ts @@ -0,0 +1,221 @@ +/* eslint-disable no-irregular-whitespace */ +// Math Formulas Content +export const MATH_FORMULAS_MD = String.raw` +# Mathematical Formulas and Expressions + +This document demonstrates various mathematical notation and formulas that can be rendered using LaTeX syntax in markdown. + +## Basic Arithmetic + +### Addition and Summation +$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$ + +## Algebra + +### Quadratic Formula +The solutions to $ax^2 + bx + c = 0$ are: +$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$ + +### Binomial Theorem +$$(x + y)^n = \sum_{k=0}^{n} \binom{n}{k} x^{n-k} y^k$$ + +## Calculus + +### Derivatives +The derivative of $f(x) = x^n$ is: +$$f'(x) = nx^{n-1}$$ + +### Integration +$$\int_a^b f(x) \, dx = F(b) - F(a)$$ + +### Fundamental Theorem of Calculus +$$\frac{d}{dx} \int_a^x f(t) \, dt = f(x)$$ + +## Linear Algebra + +### Matrix Multiplication +If $A$ is an $m \times n$ matrix and $B$ is an $n \times p$ matrix, then: +$$C_{ij} = \sum_{k=1}^{n} A_{ik} B_{kj}$$ + +### Eigenvalues and Eigenvectors +For a square matrix $A$, if $Av = \lambda v$ for some non-zero vector $v$, then: +- $\lambda$ is an eigenvalue +- $v$ is an eigenvector + +## Statistics and Probability + +### Normal Distribution +The probability density function is: +$$f(x) = \frac{1}{\sigma\sqrt{2\pi}} e^{-\frac{1}{2}\left(\frac{x-\mu}{\sigma}\right)^2}$$ + +### Bayes' Theorem +$$P(A|B) = \frac{P(B|A) \cdot P(A)}{P(B)}$$ + +### Central Limit Theorem +For large $n$, the sample mean $\bar{X}$ is approximately: +$$\bar{X} \sim N\left(\mu, \frac{\sigma^2}{n}\right)$$ + +## Trigonometry + +### Pythagorean Identity +$$\sin^2\theta + \cos^2\theta = 1$$ + +### Euler's Formula +$$e^{i\theta} = \cos\theta + i\sin\theta$$ + +### Taylor Series for Sine +$$\sin x = \sum_{n=0}^{\infty} \frac{(-1)^n}{(2n+1)!} x^{2n+1} = x - \frac{x^3}{3!} + \frac{x^5}{5!} - \frac{x^7}{7!} + \cdots$$ + +## Complex Analysis + +### Complex Numbers +A complex number can be written as: +$$z = a + bi = r e^{i\theta}$$ + +where $r = |z| = \sqrt{a^2 + b^2}$ and $\theta = \arg(z)$ + +### Cauchy-Riemann Equations +For a function $f(z) = u(x,y) + iv(x,y)$ to be analytic: +$$\frac{\partial u}{\partial x} = \frac{\partial v}{\partial y}, \quad \frac{\partial u}{\partial y} = -\frac{\partial v}{\partial x}$$ + +## Differential Equations + +### First-order Linear ODE +$$\frac{dy}{dx} + P(x)y = Q(x)$$ + +Solution: $y = e^{-\int P(x)dx}\left[\int Q(x)e^{\int P(x)dx}dx + C\right]$ + +### Heat Equation +$$\frac{\partial u}{\partial t} = \alpha \frac{\partial^2 u}{\partial x^2}$$ + +## Number Theory + +### Prime Number Theorem +$$\pi(x) \sim \frac{x}{\ln x}$$ + +where $\pi(x)$ is the number of primes less than or equal to $x$. + +### Fermat's Last Theorem +For $n > 2$, there are no positive integers $a$, $b$, and $c$ such that: +$$a^n + b^n = c^n$$ + +## Set Theory + +### De Morgan's Laws +$$\overline{A \cup B} = \overline{A} \cap \overline{B}$$ +$$\overline{A \cap B} = \overline{A} \cup \overline{B}$$ + +## Advanced Topics + +### Riemann Zeta Function +$$\zeta(s) = \sum_{n=1}^{\infty} \frac{1}{n^s} = \prod_{p \text{ prime}} \frac{1}{1-p^{-s}}$$ + +### Maxwell's Equations +$$\nabla \cdot \mathbf{E} = \frac{\rho}{\epsilon_0}$$ +$$\nabla \cdot \mathbf{B} = 0$$ +$$\nabla \times \mathbf{E} = -\frac{\partial \mathbf{B}}{\partial t}$$ +$$\nabla \times \mathbf{B} = \mu_0\mathbf{J} + \mu_0\epsilon_0\frac{\partial \mathbf{E}}{\partial t}$$ + +### Schrödinger Equation +$$i\hbar\frac{\partial}{\partial t}\Psi(\mathbf{r},t) = \hat{H}\Psi(\mathbf{r},t)$$ + +## Inline Math Examples + +Here are some inline mathematical expressions: + +- The golden ratio: $\phi = \frac{1 + \sqrt{5}}{2} \approx 1.618$ +- Euler's number: $e = \lim_{n \to \infty} \left(1 + \frac{1}{n}\right)^n$ +- Pi: $\pi = 4 \sum_{n=0}^{\infty} \frac{(-1)^n}{2n+1}$ +- Square root of 2: $\sqrt{2} = 1.41421356...$ + +## Fractions and Radicals + +Complex fraction: $\frac{\frac{a}{b} + \frac{c}{d}}{\frac{e}{f} - \frac{g}{h}}$ + +Nested radicals: $\sqrt{2 + \sqrt{3 + \sqrt{4 + \sqrt{5}}}}$ + +## Summations and Products + +### Geometric Series +$$\sum_{n=0}^{\infty} ar^n = \frac{a}{1-r} \quad \text{for } |r| < 1$$ + +### Product Notation +$$n! = \prod_{k=1}^{n} k$$ + +### Double Summation +$$\sum_{i=1}^{m} \sum_{j=1}^{n} a_{ij}$$ + +## Limits + +$$\lim_{x \to 0} \frac{\sin x}{x} = 1$$ + +$$\lim_{n \to \infty} \left(1 + \frac{x}{n}\right)^n = e^x$$ + +## Further Bracket Styles and Amounts + +- \( \mathrm{GL}_2(\mathbb{F}_7) \): Group of invertible matrices with entries in \(\mathbb{F}_7\). +- Some kernel of \(\mathrm{SL}_2(\mathbb{F}_7)\): + \[ + \left\{ \begin{pmatrix} 1 & 0 \\ 0 & 1 \end{pmatrix}, \begin{pmatrix} -1 & 0 \\ 0 & -1 \end{pmatrix} \right\} = \{\pm I\} + \] +- Algebra: +\[ +x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a} +\] +- $100 and $12.99 are amounts, not LaTeX. +- I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000. +- Emma buys 2 cupcakes for $3 each and 1 cookie for $1.50. How much money does she spend in total? +- Maria has $20. She buys a notebook for $4.75 and a pack of pencils for $3.25. How much change does she receive? +- 1 kg の質量は + \[ + E = (1\ \text{kg}) \times (3.0 \times 10^8\ \text{m/s})^2 \approx 9.0 \times 10^{16}\ \text{J} + \] + というエネルギーに相当します。これは約 21 百万トンの TNT が爆発したときのエネルギーに匹敵します。 +- Algebra: \[ +x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a} +\] +- Algebraic topology, Homotopy Groups of $\mathbb{S}^3$: +$$\pi_n(\mathbb{S}^3) = \begin{cases} +\mathbb{Z} & n = 3 \\ +0 & n > 3, n \neq 4 \\ +\mathbb{Z}_2 & n = 4 \\ +\end{cases}$$ +- Spacer preceded by backslash: +\[ +\boxed{ +\begin{aligned} +N_{\text{att}}^{\text{(MHA)}} &= +h \bigl[\, d_{\text{model}}\;d_{k} + d_{\text{model}}\;d_{v}\, \bigr] && (\text{Q,K,V の重み})\\ +&\quad+ h(d_{k}+d_{k}+d_{v}) && (\text{バイアス Q,K,V)}\\[4pt] +&\quad+ (h d_{v})\, d_{\text{model}} && (\text{出力射影 }W^{O})\\ +&\quad+ d_{\text{model}} && (\text{バイアス }b^{O}) +\end{aligned}} +\] + +## Formulas in a Table + +| Area | Expression | Comment | +|------|------------|---------| +| **Algebra** | \[ +x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a} +\] | Quadratic formula | +| | \[ +(a+b)^{n} = \sum_{k=0}^{n}\binom{n}{k}\,a^{\,n-k}\,b^{\,k} +\] | Binomial theorem | +| | \(\displaystyle \prod_{k=1}^{n}k = n! \) | Factorial definition | +| **Geometry** | \( \mathbf{a}\cdot \mathbf{b} = \|\mathbf{a}\|\,\|\mathbf{b}\|\,\cos\theta \) | Dot product & angle | + +## No math (but chemical) + +Balanced chemical reaction with states: + +\[ +\ce{2H2(g) + O2(g) -> 2H2O(l)} +\] + +The standard enthalpy change for the reaction is: $\Delta H^\circ = \pu{-572 kJ mol^{-1}}$. + +--- + +*This document showcases various mathematical notation and formulas that can be rendered in markdown using LaTeX syntax.* +`; diff --git a/tools/ui/tests/stories/fixtures/readme.ts b/tools/ui/tests/stories/fixtures/readme.ts new file mode 100644 index 000000000..e8b573d6c --- /dev/null +++ b/tools/ui/tests/stories/fixtures/readme.ts @@ -0,0 +1,136 @@ +// README Content +export const README_MD = String.raw` +# 🚀 Awesome Web Framework + +[![npm version](https://img.shields.io/npm/v/awesome-framework.svg)](https://www.npmjs.com/package/awesome-framework) +[![Build Status](https://github.com/awesome/framework/workflows/CI/badge.svg)](https://github.com/awesome/framework/actions) +[![Coverage](https://codecov.io/gh/awesome/framework/branch/main/graph/badge.svg)](https://codecov.io/gh/awesome/framework) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +> A modern, fast, and flexible web framework for building scalable applications + +## ✨ Features + +- 🎯 **Type-Safe** - Full TypeScript support out of the box +- ⚡ **Lightning Fast** - Built on Vite for instant HMR +- 📦 **Zero Config** - Works out of the box for most use cases +- 🎨 **Flexible** - Unopinionated with sensible defaults +- 🔧 **Extensible** - Plugin system for custom functionality +- 📱 **Responsive** - Mobile-first approach +- 🌍 **i18n Ready** - Built-in internationalization +- 🔒 **Secure** - Security best practices by default + +## 📦 Installation + +${'```'}bash +npm install awesome-framework +# or +yarn add awesome-framework +# or +pnpm add awesome-framework +${'```'} + +## 🚀 Quick Start + +### Create a new project + +${'```'}bash +npx create-awesome-app my-app +cd my-app +npm run dev +${'```'} + +### Basic Example + +${'```'}javascript +import { createApp } from 'awesome-framework'; + +const app = createApp({ + port: 3000, + middleware: ['cors', 'helmet', 'compression'] +}); + +app.get('/', (req, res) => { + res.json({ message: 'Hello World!' }); +}); + +app.listen(() => { + console.log('Server running on http://localhost:3000'); +}); +${'```'} + +## 📖 Documentation + +### Core Concepts + +- [Getting Started](https://docs.awesome.dev/getting-started) +- [Configuration](https://docs.awesome.dev/configuration) +- [Routing](https://docs.awesome.dev/routing) +- [Middleware](https://docs.awesome.dev/middleware) +- [Database](https://docs.awesome.dev/database) +- [Authentication](https://docs.awesome.dev/authentication) + +### Advanced Topics + +- [Performance Optimization](https://docs.awesome.dev/performance) +- [Deployment](https://docs.awesome.dev/deployment) +- [Testing](https://docs.awesome.dev/testing) +- [Security](https://docs.awesome.dev/security) + +## 🛠️ Development + +### Prerequisites + +- Node.js >= 18 +- pnpm >= 8 + +### Setup + +${'```'}bash +git clone https://github.com/awesome/framework.git +cd framework +pnpm install +pnpm dev +${'```'} + +### Testing + +${'```'}bash +pnpm test # Run unit tests +pnpm test:e2e # Run end-to-end tests +pnpm test:watch # Run tests in watch mode +${'```'} + +## 🤝 Contributing + +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. + +### Contributors + +
                + + + +## 📊 Benchmarks + +| Framework | Requests/sec | Latency (ms) | Memory (MB) | +|-----------|-------------|--------------|-------------| +| **Awesome** | **45,230** | **2.1** | **42** | +| Express | 28,450 | 3.5 | 68 | +| Fastify | 41,200 | 2.3 | 48 | +| Koa | 32,100 | 3.1 | 52 | + +*Benchmarks performed on MacBook Pro M2, Node.js 20.x* + +## 📝 License + +MIT © [Awesome Team](https://github.com/awesome) + +## 🙏 Acknowledgments + +Special thanks to all our sponsors and contributors who make this project possible. + +--- + +**[Website](https://awesome.dev)** • **[Documentation](https://docs.awesome.dev)** • **[Discord](https://discord.gg/awesome)** • **[Twitter](https://twitter.com/awesomeframework)** +`; diff --git a/tools/ui/tests/stories/fixtures/storybook-mocks.ts b/tools/ui/tests/stories/fixtures/storybook-mocks.ts new file mode 100644 index 000000000..c40a74655 --- /dev/null +++ b/tools/ui/tests/stories/fixtures/storybook-mocks.ts @@ -0,0 +1,81 @@ +import { serverStore } from '$lib/stores/server.svelte'; +import { modelsStore } from '$lib/stores/models.svelte'; + +/** + * Mock server properties for Storybook testing + * This utility allows setting mock server configurations without polluting production code + */ +export function mockServerProps(props: Partial): void { + // Reset any pointer-events from previous tests (dropdown cleanup) + const body = document.querySelector('body'); + if (body) body.style.pointerEvents = ''; + + // Directly set the props for testing purposes + (serverStore as unknown as { props: ApiLlamaCppServerProps }).props = { + model_path: props.model_path || 'test-model', + modalities: { + vision: props.modalities?.vision ?? false, + audio: props.modalities?.audio ?? false + }, + ...props + } as ApiLlamaCppServerProps; + + // Set router mode role so activeModelId can be set + (serverStore as unknown as { props: ApiLlamaCppServerProps }).props.role = 'ROUTER'; + + // Also mock modelsStore methods for modality checking + const vision = props.modalities?.vision ?? false; + const audio = props.modalities?.audio ?? false; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (modelsStore as any).modelSupportsVision = () => vision; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (modelsStore as any).modelSupportsAudio = () => audio; + + // Mock models list with a test model so activeModelId can be resolved + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (modelsStore as any).models = [ + { + id: 'test-model', + name: 'Test Model', + model: 'test-model' + } + ]; + + // Mock selectedModelId + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (modelsStore as any).selectedModelId = 'test-model'; +} + +/** + * Reset server store to clean state for testing + */ +export function resetServerStore(): void { + (serverStore as unknown as { props: ApiLlamaCppServerProps }).props = { + model_path: '', + modalities: { + vision: false, + audio: false + } + } as ApiLlamaCppServerProps; + (serverStore as unknown as { error: string }).error = ''; + (serverStore as unknown as { loading: boolean }).loading = false; +} + +/** + * Common mock configurations for Storybook stories + */ +export const mockConfigs = { + visionOnly: { + modalities: { vision: true, audio: false } + }, + audioOnly: { + modalities: { vision: false, audio: true } + }, + bothModalities: { + modalities: { vision: true, audio: true } + }, + noModalities: { + modalities: { vision: false, audio: false } + } +} as const; diff --git a/tools/ui/tests/unit/agentic-sections.test.ts b/tools/ui/tests/unit/agentic-sections.test.ts new file mode 100644 index 000000000..7af882143 --- /dev/null +++ b/tools/ui/tests/unit/agentic-sections.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect } from 'vitest'; +import { deriveAgenticSections, hasAgenticContent } from '$lib/utils/agentic'; +import { AgenticSectionType, MessageRole } from '$lib/enums'; +import type { DatabaseMessage } from '$lib/types/database'; +import type { ApiChatCompletionToolCall } from '$lib/types/api'; + +function makeAssistant(overrides: Partial = {}): DatabaseMessage { + return { + id: overrides.id ?? 'ast-1', + convId: 'conv-1', + type: 'text', + timestamp: Date.now(), + role: MessageRole.ASSISTANT, + content: overrides.content ?? '', + parent: null, + children: [], + ...overrides + } as DatabaseMessage; +} + +function makeToolMsg(overrides: Partial = {}): DatabaseMessage { + return { + id: overrides.id ?? 'tool-1', + convId: 'conv-1', + type: 'text', + timestamp: Date.now(), + role: MessageRole.TOOL, + content: overrides.content ?? 'tool result', + parent: null, + children: [], + toolCallId: overrides.toolCallId ?? 'call_1', + ...overrides + } as DatabaseMessage; +} + +describe('deriveAgenticSections', () => { + it('returns empty array for assistant with no content', () => { + const msg = makeAssistant({ content: '' }); + const sections = deriveAgenticSections(msg); + expect(sections).toEqual([]); + }); + + it('returns text section for simple assistant message', () => { + const msg = makeAssistant({ content: 'Hello world' }); + const sections = deriveAgenticSections(msg); + expect(sections).toHaveLength(1); + expect(sections[0].type).toBe(AgenticSectionType.TEXT); + expect(sections[0].content).toBe('Hello world'); + }); + + it('returns reasoning + text for message with reasoning', () => { + const msg = makeAssistant({ + content: 'Answer is 4.', + reasoningContent: 'Let me think...' + }); + const sections = deriveAgenticSections(msg); + expect(sections).toHaveLength(2); + expect(sections[0].type).toBe(AgenticSectionType.REASONING); + expect(sections[0].content).toBe('Let me think...'); + expect(sections[1].type).toBe(AgenticSectionType.TEXT); + }); + + it('single turn: assistant with tool calls and results', () => { + const msg = makeAssistant({ + content: 'Let me check.', + toolCalls: JSON.stringify([ + { + id: 'call_1', + type: 'function', + function: { name: 'search', arguments: '{"q":"test"}' } + } + ]) + }); + const toolResult = makeToolMsg({ + toolCallId: 'call_1', + content: 'Found 3 results' + }); + const sections = deriveAgenticSections(msg, [toolResult]); + expect(sections).toHaveLength(2); + expect(sections[0].type).toBe(AgenticSectionType.TEXT); + expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL); + expect(sections[1].toolName).toBe('search'); + expect(sections[1].toolResult).toBe('Found 3 results'); + }); + + it('single turn: pending tool call without result', () => { + const msg = makeAssistant({ + toolCalls: JSON.stringify([ + { id: 'call_1', type: 'function', function: { name: 'bash', arguments: '{}' } } + ]) + }); + const sections = deriveAgenticSections(msg, [], [], true); + expect(sections).toHaveLength(1); + expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING); + expect(sections[0].toolName).toBe('bash'); + }); + + it('multi-turn: two assistant turns grouped as one session', () => { + const assistant1 = makeAssistant({ + id: 'ast-1', + content: 'Turn 1 text', + toolCalls: JSON.stringify([ + { + id: 'call_1', + type: 'function', + function: { name: 'search', arguments: '{"q":"foo"}' } + } + ]) + }); + const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'result 1' }); + const assistant2 = makeAssistant({ + id: 'ast-2', + content: 'Final answer based on results.' + }); + + // toolMessages contains both tool result and continuation assistant + const sections = deriveAgenticSections(assistant1, [tool1, assistant2]); + expect(sections).toHaveLength(3); + // Turn 1 + expect(sections[0].type).toBe(AgenticSectionType.TEXT); + expect(sections[0].content).toBe('Turn 1 text'); + expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL); + expect(sections[1].toolName).toBe('search'); + expect(sections[1].toolResult).toBe('result 1'); + // Turn 2 (final) + expect(sections[2].type).toBe(AgenticSectionType.TEXT); + expect(sections[2].content).toBe('Final answer based on results.'); + }); + + it('multi-turn: three turns with tool calls', () => { + const assistant1 = makeAssistant({ + id: 'ast-1', + content: '', + toolCalls: JSON.stringify([ + { + id: 'call_1', + type: 'function', + function: { name: 'list_files', arguments: '{}' } + } + ]) + }); + const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'file1 file2' }); + const assistant2 = makeAssistant({ + id: 'ast-2', + content: 'Reading file1...', + toolCalls: JSON.stringify([ + { + id: 'call_2', + type: 'function', + function: { name: 'read_file', arguments: '{"path":"file1"}' } + } + ]) + }); + const tool2 = makeToolMsg({ + id: 'tool-2', + toolCallId: 'call_2', + content: 'contents of file1' + }); + const assistant3 = makeAssistant({ + id: 'ast-3', + content: 'Here is the analysis.', + reasoningContent: 'The file contains...' + }); + + const sections = deriveAgenticSections(assistant1, [tool1, assistant2, tool2, assistant3]); + // Turn 1: tool_call (no text since content is empty) + // Turn 2: text + tool_call + // Turn 3: reasoning + text + expect(sections).toHaveLength(5); + expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL); + expect(sections[0].toolName).toBe('list_files'); + expect(sections[1].type).toBe(AgenticSectionType.TEXT); + expect(sections[1].content).toBe('Reading file1...'); + expect(sections[2].type).toBe(AgenticSectionType.TOOL_CALL); + expect(sections[2].toolName).toBe('read_file'); + expect(sections[3].type).toBe(AgenticSectionType.REASONING); + expect(sections[4].type).toBe(AgenticSectionType.TEXT); + expect(sections[4].content).toBe('Here is the analysis.'); + }); + + it('returns REASONING_PENDING when streaming with only reasoning content', () => { + const msg = makeAssistant({ + reasoningContent: 'Let me think about this...' + }); + const sections = deriveAgenticSections(msg, [], [], true); + expect(sections).toHaveLength(1); + expect(sections[0].type).toBe(AgenticSectionType.REASONING_PENDING); + expect(sections[0].content).toBe('Let me think about this...'); + }); + + it('returns REASONING (not pending) when streaming but text content has appeared', () => { + const msg = makeAssistant({ + content: 'The answer is', + reasoningContent: 'Let me think...' + }); + const sections = deriveAgenticSections(msg, [], [], true); + expect(sections).toHaveLength(2); + expect(sections[0].type).toBe(AgenticSectionType.REASONING); + expect(sections[1].type).toBe(AgenticSectionType.TEXT); + }); + + it('returns REASONING (not pending) when not streaming', () => { + const msg = makeAssistant({ + reasoningContent: 'Let me think...' + }); + const sections = deriveAgenticSections(msg, [], [], false); + expect(sections).toHaveLength(1); + expect(sections[0].type).toBe(AgenticSectionType.REASONING); + }); + + it('multi-turn: streaming tool calls on last turn', () => { + const assistant1 = makeAssistant({ + toolCalls: JSON.stringify([ + { id: 'call_1', type: 'function', function: { name: 'search', arguments: '{}' } } + ]) + }); + const tool1 = makeToolMsg({ toolCallId: 'call_1', content: 'result' }); + const assistant2 = makeAssistant({ id: 'ast-2', content: '' }); + + const streamingToolCalls: ApiChatCompletionToolCall[] = [ + { id: 'call_2', type: 'function', function: { name: 'write_file', arguments: '{"pa' } } + ]; + + const sections = deriveAgenticSections(assistant1, [tool1, assistant2], streamingToolCalls); + // Turn 1: tool_call + // Turn 2 (streaming): streaming tool call + expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL)).toBe(true); + expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL_STREAMING)).toBe(true); + }); +}); + +describe('hasAgenticContent', () => { + it('returns false for plain assistant', () => { + const msg = makeAssistant({ content: 'Just text' }); + expect(hasAgenticContent(msg)).toBe(false); + }); + + it('returns true when message has toolCalls', () => { + const msg = makeAssistant({ + toolCalls: JSON.stringify([ + { id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } } + ]) + }); + expect(hasAgenticContent(msg)).toBe(true); + }); + + it('returns true when toolMessages are provided', () => { + const msg = makeAssistant(); + const tool = makeToolMsg(); + expect(hasAgenticContent(msg, [tool])).toBe(true); + }); + + it('returns false for empty toolCalls JSON', () => { + const msg = makeAssistant({ toolCalls: '[]' }); + expect(hasAgenticContent(msg)).toBe(false); + }); +}); diff --git a/tools/ui/tests/unit/agentic-strip.test.ts b/tools/ui/tests/unit/agentic-strip.test.ts new file mode 100644 index 000000000..86867f8a9 --- /dev/null +++ b/tools/ui/tests/unit/agentic-strip.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { LEGACY_AGENTIC_REGEX } from '$lib/constants/agentic'; + +/** + * Tests for legacy marker stripping (used in migration). + * The new system does not embed markers in content - these tests verify + * the legacy regex patterns still work for the migration code. + */ + +// Mirror the legacy stripping logic used during migration +function stripLegacyContextMarkers(content: string): string { + return content + .replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '') + .replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '') + .replace(new RegExp(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK.source, 'g'), '') + .replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, ''); +} + +// A realistic complete tool call block as stored in old message.content +const COMPLETE_BLOCK = + '\n\n<<>>\n' + + '<<>>\n' + + '<<>>\n' + + '{"command":"ls /tmp","description":"list tmp"}\n' + + '<<>>\n' + + 'file1.txt\nfile2.txt\n' + + '<<>>\n'; + +// Partial block: streaming was cut before END arrived. +const OPEN_BLOCK = + '\n\n<<>>\n' + + '<<>>\n' + + '<<>>\n' + + '{"command":"ls /tmp","description":"list tmp"}\n' + + '<<>>\n' + + 'partial output...'; + +describe('legacy agentic marker stripping (for migration)', () => { + it('strips a complete tool call block, leaving surrounding text', () => { + const input = 'Before.' + COMPLETE_BLOCK + 'After.'; + const result = stripLegacyContextMarkers(input); + expect(result).not.toContain('<<<'); + expect(result).toContain('Before.'); + expect(result).toContain('After.'); + }); + + it('strips multiple complete tool call blocks', () => { + const input = 'A' + COMPLETE_BLOCK + 'B' + COMPLETE_BLOCK + 'C'; + const result = stripLegacyContextMarkers(input); + expect(result).not.toContain('<<<'); + expect(result).toContain('A'); + expect(result).toContain('B'); + expect(result).toContain('C'); + }); + + it('strips an open/partial tool call block (no END marker)', () => { + const input = 'Lead text.' + OPEN_BLOCK; + const result = stripLegacyContextMarkers(input); + expect(result).toBe('Lead text.'); + expect(result).not.toContain('<<<'); + }); + + it('does not alter content with no markers', () => { + const input = 'Just a normal assistant response.'; + expect(stripLegacyContextMarkers(input)).toBe(input); + }); + + it('strips reasoning block independently', () => { + const input = '<<>>think hard<<>>Answer.'; + expect(stripLegacyContextMarkers(input)).toBe('Answer.'); + }); + + it('strips both reasoning and agentic blocks together', () => { + const input = + '<<>>plan<<>>' + + 'Some text.' + + COMPLETE_BLOCK; + expect(stripLegacyContextMarkers(input)).not.toContain('<<<'); + expect(stripLegacyContextMarkers(input)).toContain('Some text.'); + }); + + it('empty string survives', () => { + expect(stripLegacyContextMarkers('')).toBe(''); + }); + + it('detects legacy markers', () => { + expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('normal text')).toBe(false); + expect( + LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('text<<>>more') + ).toBe(true); + expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('<<>>think')).toBe( + true + ); + }); +}); diff --git a/tools/ui/tests/unit/clipboard.test.ts b/tools/ui/tests/unit/clipboard.test.ts new file mode 100644 index 000000000..d8ea4899e --- /dev/null +++ b/tools/ui/tests/unit/clipboard.test.ts @@ -0,0 +1,423 @@ +import { describe, it, expect } from 'vitest'; +import { AttachmentType } from '$lib/enums'; +import { + formatMessageForClipboard, + parseClipboardContent, + hasClipboardAttachments +} from '$lib/utils/clipboard'; + +describe('formatMessageForClipboard', () => { + it('returns plain content when no extras', () => { + const result = formatMessageForClipboard('Hello world', undefined); + expect(result).toBe('Hello world'); + }); + + it('returns plain content when extras is empty array', () => { + const result = formatMessageForClipboard('Hello world', []); + expect(result).toBe('Hello world'); + }); + + it('handles empty string content', () => { + const result = formatMessageForClipboard('', undefined); + expect(result).toBe(''); + }); + + it('returns plain content when extras has only non-text attachments', () => { + const extras = [ + { + type: AttachmentType.IMAGE as const, + name: 'image.png', + base64Url: 'data:image/png;base64,...' + } + ]; + const result = formatMessageForClipboard('Hello world', extras); + expect(result).toBe('Hello world'); + }); + + it('filters non-text attachments and keeps only text ones', () => { + const extras = [ + { + type: AttachmentType.IMAGE as const, + name: 'image.png', + base64Url: 'data:image/png;base64,...' + }, + { + type: AttachmentType.TEXT as const, + name: 'file.txt', + content: 'Text content' + }, + { + type: AttachmentType.PDF as const, + name: 'doc.pdf', + base64Data: 'data:application/pdf;base64,...', + content: 'PDF content', + processedAsImages: false + } + ]; + const result = formatMessageForClipboard('Hello', extras); + + expect(result).toContain('"file.txt"'); + expect(result).not.toContain('image.png'); + expect(result).not.toContain('doc.pdf'); + }); + + it('formats message with text attachments', () => { + const extras = [ + { + type: AttachmentType.TEXT as const, + name: 'file1.txt', + content: 'File 1 content' + }, + { + type: AttachmentType.TEXT as const, + name: 'file2.txt', + content: 'File 2 content' + } + ]; + const result = formatMessageForClipboard('Hello world', extras); + + expect(result).toContain('"Hello world"'); + expect(result).toContain('"type": "TEXT"'); + expect(result).toContain('"name": "file1.txt"'); + expect(result).toContain('"content": "File 1 content"'); + expect(result).toContain('"name": "file2.txt"'); + }); + + it('handles content with quotes and special characters', () => { + const content = 'Hello "world" with\nnewline'; + const extras = [ + { + type: AttachmentType.TEXT as const, + name: 'test.txt', + content: 'Test content' + } + ]; + const result = formatMessageForClipboard(content, extras); + + // Should be valid JSON + expect(result.startsWith('"')).toBe(true); + // The content should be properly escaped + const parsed = JSON.parse(result.split('\n')[0]); + expect(parsed).toBe(content); + }); + + it('converts legacy context type to TEXT type', () => { + const extras = [ + { + type: AttachmentType.LEGACY_CONTEXT as const, + name: 'legacy.txt', + content: 'Legacy content' + } + ]; + const result = formatMessageForClipboard('Hello', extras); + + expect(result).toContain('"type": "TEXT"'); + expect(result).not.toContain('"context"'); + }); + + it('handles attachment content with special characters', () => { + const extras = [ + { + type: AttachmentType.TEXT as const, + name: 'code.js', + content: 'const x = "hello\\nworld";\nconst y = `template ${var}`;' + } + ]; + const formatted = formatMessageForClipboard('Check this code', extras); + const parsed = parseClipboardContent(formatted); + + expect(parsed.textAttachments[0].content).toBe( + 'const x = "hello\\nworld";\nconst y = `template ${var}`;' + ); + }); + + it('handles unicode characters in content and attachments', () => { + const extras = [ + { + type: AttachmentType.TEXT as const, + name: 'unicode.txt', + content: '日本語テスト 🎉 émojis' + } + ]; + const formatted = formatMessageForClipboard('Привет мир 👋', extras); + const parsed = parseClipboardContent(formatted); + + expect(parsed.message).toBe('Привет мир 👋'); + expect(parsed.textAttachments[0].content).toBe('日本語テスト 🎉 émojis'); + }); + + it('formats as plain text when asPlainText is true', () => { + const extras = [ + { + type: AttachmentType.TEXT as const, + name: 'file1.txt', + content: 'File 1 content' + }, + { + type: AttachmentType.TEXT as const, + name: 'file2.txt', + content: 'File 2 content' + } + ]; + const result = formatMessageForClipboard('Hello world', extras, true); + + expect(result).toBe('Hello world\n\nFile 1 content\n\nFile 2 content'); + }); + + it('returns plain content when asPlainText is true but no attachments', () => { + const result = formatMessageForClipboard('Hello world', [], true); + expect(result).toBe('Hello world'); + }); + + it('plain text mode does not use JSON format', () => { + const extras = [ + { + type: AttachmentType.TEXT as const, + name: 'test.txt', + content: 'Test content' + } + ]; + const result = formatMessageForClipboard('Hello', extras, true); + + expect(result).not.toContain('"type"'); + expect(result).not.toContain('['); + expect(result).toBe('Hello\n\nTest content'); + }); +}); + +describe('parseClipboardContent', () => { + it('returns plain text as message when not in special format', () => { + const result = parseClipboardContent('Hello world'); + + expect(result.message).toBe('Hello world'); + expect(result.textAttachments).toHaveLength(0); + }); + + it('handles empty string input', () => { + const result = parseClipboardContent(''); + + expect(result.message).toBe(''); + expect(result.textAttachments).toHaveLength(0); + }); + + it('handles whitespace-only input', () => { + const result = parseClipboardContent(' \n\t '); + + expect(result.message).toBe(' \n\t '); + expect(result.textAttachments).toHaveLength(0); + }); + + it('returns plain text as message when starts with quote but invalid format', () => { + const result = parseClipboardContent('"Unclosed quote'); + + expect(result.message).toBe('"Unclosed quote'); + expect(result.textAttachments).toHaveLength(0); + }); + + it('returns original text when JSON array is malformed', () => { + const input = '"Hello"\n[invalid json'; + + const result = parseClipboardContent(input); + + expect(result.message).toBe('"Hello"\n[invalid json'); + expect(result.textAttachments).toHaveLength(0); + }); + + it('parses message with text attachments', () => { + const input = `"Hello world" +[ + {"type":"TEXT","name":"file1.txt","content":"File 1 content"}, + {"type":"TEXT","name":"file2.txt","content":"File 2 content"} +]`; + + const result = parseClipboardContent(input); + + expect(result.message).toBe('Hello world'); + expect(result.textAttachments).toHaveLength(2); + expect(result.textAttachments[0].name).toBe('file1.txt'); + expect(result.textAttachments[0].content).toBe('File 1 content'); + expect(result.textAttachments[1].name).toBe('file2.txt'); + expect(result.textAttachments[1].content).toBe('File 2 content'); + }); + + it('handles escaped quotes in message', () => { + const input = `"Hello \\"world\\" with quotes" +[ + {"type":"TEXT","name":"file.txt","content":"test"} +]`; + + const result = parseClipboardContent(input); + + expect(result.message).toBe('Hello "world" with quotes'); + expect(result.textAttachments).toHaveLength(1); + }); + + it('handles newlines in message', () => { + const input = `"Hello\\nworld" +[ + {"type":"TEXT","name":"file.txt","content":"test"} +]`; + + const result = parseClipboardContent(input); + + expect(result.message).toBe('Hello\nworld'); + expect(result.textAttachments).toHaveLength(1); + }); + + it('returns message only when no array follows', () => { + const input = '"Just a quoted string"'; + + const result = parseClipboardContent(input); + + expect(result.message).toBe('Just a quoted string'); + expect(result.textAttachments).toHaveLength(0); + }); + + it('filters out invalid attachment objects', () => { + const input = `"Hello" +[ + {"type":"TEXT","name":"valid.txt","content":"valid"}, + {"type":"INVALID","name":"invalid.txt","content":"invalid"}, + {"name":"missing-type.txt","content":"missing"}, + {"type":"TEXT","content":"missing name"} +]`; + + const result = parseClipboardContent(input); + + expect(result.message).toBe('Hello'); + expect(result.textAttachments).toHaveLength(1); + expect(result.textAttachments[0].name).toBe('valid.txt'); + }); + + it('handles empty attachments array', () => { + const input = '"Hello"\n[]'; + + const result = parseClipboardContent(input); + + expect(result.message).toBe('Hello'); + expect(result.textAttachments).toHaveLength(0); + }); + + it('roundtrips correctly with formatMessageForClipboard', () => { + const originalContent = 'Hello "world" with\nspecial characters'; + const originalExtras = [ + { + type: AttachmentType.TEXT as const, + name: 'file1.txt', + content: 'Content with\nnewlines and "quotes"' + }, + { + type: AttachmentType.TEXT as const, + name: 'file2.txt', + content: 'Another file' + } + ]; + + const formatted = formatMessageForClipboard(originalContent, originalExtras); + const parsed = parseClipboardContent(formatted); + + expect(parsed.message).toBe(originalContent); + expect(parsed.textAttachments).toHaveLength(2); + expect(parsed.textAttachments[0].name).toBe('file1.txt'); + expect(parsed.textAttachments[0].content).toBe('Content with\nnewlines and "quotes"'); + expect(parsed.textAttachments[1].name).toBe('file2.txt'); + expect(parsed.textAttachments[1].content).toBe('Another file'); + }); +}); + +describe('hasClipboardAttachments', () => { + it('returns false for plain text', () => { + expect(hasClipboardAttachments('Hello world')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(hasClipboardAttachments('')).toBe(false); + }); + + it('returns false for quoted string without attachments', () => { + expect(hasClipboardAttachments('"Hello world"')).toBe(false); + }); + + it('returns true for valid format with attachments', () => { + const input = `"Hello" +[{"type":"TEXT","name":"file.txt","content":"test"}]`; + + expect(hasClipboardAttachments(input)).toBe(true); + }); + + it('returns false for format with empty attachments array', () => { + const input = '"Hello"\n[]'; + + expect(hasClipboardAttachments(input)).toBe(false); + }); + + it('returns false for malformed JSON', () => { + expect(hasClipboardAttachments('"Hello"\n[broken')).toBe(false); + }); +}); + +describe('roundtrip edge cases', () => { + it('preserves empty message with attachments', () => { + const extras = [ + { + type: AttachmentType.TEXT as const, + name: 'file.txt', + content: 'Content only' + } + ]; + const formatted = formatMessageForClipboard('', extras); + const parsed = parseClipboardContent(formatted); + + expect(parsed.message).toBe(''); + expect(parsed.textAttachments).toHaveLength(1); + expect(parsed.textAttachments[0].content).toBe('Content only'); + }); + + it('preserves attachment with empty content', () => { + const extras = [ + { + type: AttachmentType.TEXT as const, + name: 'empty.txt', + content: '' + } + ]; + const formatted = formatMessageForClipboard('Message', extras); + const parsed = parseClipboardContent(formatted); + + expect(parsed.message).toBe('Message'); + expect(parsed.textAttachments).toHaveLength(1); + expect(parsed.textAttachments[0].content).toBe(''); + }); + + it('preserves multiple backslashes', () => { + const content = 'Path: C:\\\\Users\\\\test\\\\file.txt'; + const extras = [ + { + type: AttachmentType.TEXT as const, + name: 'path.txt', + content: 'D:\\\\Data\\\\file' + } + ]; + const formatted = formatMessageForClipboard(content, extras); + const parsed = parseClipboardContent(formatted); + + expect(parsed.message).toBe(content); + expect(parsed.textAttachments[0].content).toBe('D:\\\\Data\\\\file'); + }); + + it('preserves tabs and various whitespace', () => { + const content = 'Line1\t\tTabbed\n Spaced\r\nCRLF'; + const extras = [ + { + type: AttachmentType.TEXT as const, + name: 'whitespace.txt', + content: '\t\t\n\n ' + } + ]; + const formatted = formatMessageForClipboard(content, extras); + const parsed = parseClipboardContent(formatted); + + expect(parsed.message).toBe(content); + expect(parsed.textAttachments[0].content).toBe('\t\t\n\n '); + }); +}); diff --git a/tools/ui/tests/unit/latex-protection.test.ts b/tools/ui/tests/unit/latex-protection.test.ts new file mode 100644 index 000000000..84328dbc1 --- /dev/null +++ b/tools/ui/tests/unit/latex-protection.test.ts @@ -0,0 +1,376 @@ +/* eslint-disable no-irregular-whitespace */ +import { describe, it, expect, test } from 'vitest'; +import { maskInlineLaTeX, preprocessLaTeX } from '$lib/utils/latex-protection'; + +describe('maskInlineLaTeX', () => { + it('should protect LaTeX $x + y$ but not money $3.99', () => { + const latexExpressions: string[] = []; + const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.'; + const output = maskInlineLaTeX(input, latexExpressions); + + expect(output).toBe('I have $10, $3.99 and <> and <>. The amount is $2,000.'); + expect(latexExpressions).toEqual(['$x + y$', '$100x$']); + }); + + it('should ignore money like $5 and $12.99', () => { + const latexExpressions: string[] = []; + const input = 'Prices are $12.99 and $5. Tax?'; + const output = maskInlineLaTeX(input, latexExpressions); + + expect(output).toBe('Prices are $12.99 and $5. Tax?'); + expect(latexExpressions).toEqual([]); + }); + + it('should protect inline math $a^2 + b^2$ even after text', () => { + const latexExpressions: string[] = []; + const input = 'Pythagorean: $a^2 + b^2 = c^2$.'; + const output = maskInlineLaTeX(input, latexExpressions); + + expect(output).toBe('Pythagorean: <>.'); + expect(latexExpressions).toEqual(['$a^2 + b^2 = c^2$']); + }); + + it('should not protect math that has letter after closing $ (e.g. units)', () => { + const latexExpressions: string[] = []; + const input = 'The cost is $99 and change.'; + const output = maskInlineLaTeX(input, latexExpressions); + + expect(output).toBe('The cost is $99 and change.'); + expect(latexExpressions).toEqual([]); + }); + + it('should allow $x$ followed by punctuation', () => { + const latexExpressions: string[] = []; + const input = 'We know $x$, right?'; + const output = maskInlineLaTeX(input, latexExpressions); + + expect(output).toBe('We know <>, right?'); + expect(latexExpressions).toEqual(['$x$']); + }); + + it('should work across multiple lines', () => { + const latexExpressions: string[] = []; + const input = `Emma buys cupcakes for $3 each.\nHow much is $x + y$?`; + const output = maskInlineLaTeX(input, latexExpressions); + + expect(output).toBe(`Emma buys cupcakes for $3 each.\nHow much is <>?`); + expect(latexExpressions).toEqual(['$x + y$']); + }); + + it('should not protect $100 but protect $matrix$', () => { + const latexExpressions: string[] = []; + const input = '$100 and $\\mathrm{GL}_2(\\mathbb{F}_7)$ are different.'; + const output = maskInlineLaTeX(input, latexExpressions); + + expect(output).toBe('$100 and <> are different.'); + expect(latexExpressions).toEqual(['$\\mathrm{GL}_2(\\mathbb{F}_7)$']); + }); + + it('should skip if $ is followed by digit and alphanumeric after close (money)', () => { + const latexExpressions: string[] = []; + const input = 'I paid $5 quickly.'; + const output = maskInlineLaTeX(input, latexExpressions); + + expect(output).toBe('I paid $5 quickly.'); + expect(latexExpressions).toEqual([]); + }); + + it('should protect LaTeX even with special chars inside', () => { + const latexExpressions: string[] = []; + const input = 'Consider $\\alpha_1 + \\beta_2$ now.'; + const output = maskInlineLaTeX(input, latexExpressions); + + expect(output).toBe('Consider <> now.'); + expect(latexExpressions).toEqual(['$\\alpha_1 + \\beta_2$']); + }); + + it('short text', () => { + const latexExpressions: string[] = ['$0$']; + const input = '$a$\n$a$ and $b$'; + const output = maskInlineLaTeX(input, latexExpressions); + + expect(output).toBe('<>\n<> and <>'); + expect(latexExpressions).toEqual(['$0$', '$a$', '$a$', '$b$']); + }); + + it('empty text', () => { + const latexExpressions: string[] = []; + const input = '$\n$$\n'; + const output = maskInlineLaTeX(input, latexExpressions); + + expect(output).toBe('$\n$$\n'); + expect(latexExpressions).toEqual([]); + }); + + it('LaTeX-spacer preceded by backslash', () => { + const latexExpressions: string[] = []; + const input = `\\[ +\\boxed{ +\\begin{aligned} +N_{\\text{att}}^{\\text{(MHA)}} &= +h \\bigl[\\, d_{\\text{model}}\\;d_{k} + d_{\\text{model}}\\;d_{v}\\, \\bigr] && (\\text{Q,K,V の重み})\\\\ +&\\quad+ h(d_{k}+d_{k}+d_{v}) && (\\text{バイアス Q,K,V)}\\\\[4pt] +&\\quad+ (h d_{v})\\, d_{\\text{model}} && (\\text{出力射影 }W^{O})\\\\ +&\\quad+ d_{\\text{model}} && (\\text{バイアス }b^{O}) +\\end{aligned}} +\\]`; + const output = maskInlineLaTeX(input, latexExpressions); + + expect(output).toBe(input); + expect(latexExpressions).toEqual([]); + }); +}); + +describe('preprocessLaTeX', () => { + test('converts inline \\( ... \\) to $...$', () => { + const input = + '\\( \\mathrm{GL}_2(\\mathbb{F}_7) \\): Group of invertible matrices with entries in \\(\\mathbb{F}_7\\).'; + const output = preprocessLaTeX(input); + expect(output).toBe( + '$ \\mathrm{GL}_2(\\mathbb{F}_7) $: Group of invertible matrices with entries in $\\mathbb{F}_7$.' + ); + }); + + test("don't inline \\\\( ... \\) to $...$", () => { + const input = + 'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula \\((x_1,\\ldots,x_n)\\).'; + const output = preprocessLaTeX(input); + expect(output).toBe( + 'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula $(x_1,\\ldots,x_n)$.' + ); + }); + + test('preserves display math \\[ ... \\] and protects adjacent text', () => { + const input = `Some kernel of \\(\\mathrm{SL}_2(\\mathbb{F}_7)\\): + \\[ + \\left\\{ \\begin{pmatrix} 1 & 0 \\\\ 0 & 1 \\end{pmatrix}, \\begin{pmatrix} -1 & 0 \\\\ 0 & -1 \\end{pmatrix} \\right\\} = \\{\\pm I\\} + \\]`; + const output = preprocessLaTeX(input); + + expect(output).toBe(`Some kernel of $\\mathrm{SL}_2(\\mathbb{F}_7)$: + $$ + \\left\\{ \\begin{pmatrix} 1 & 0 \\\\ 0 & 1 \\end{pmatrix}, \\begin{pmatrix} -1 & 0 \\\\ 0 & -1 \\end{pmatrix} \\right\\} = \\{\\pm I\\} + $$`); + }); + + test('handles standalone display math equation', () => { + const input = `Algebra: +\\[ +x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a} +\\]`; + const output = preprocessLaTeX(input); + + expect(output).toBe(`Algebra: +$$ +x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a} +$$`); + }); + + test('does not interpret currency values as LaTeX', () => { + const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.'; + const output = preprocessLaTeX(input); + + expect(output).toBe('I have \\$10, \\$3.99 and $x + y$ and $100x$. The amount is \\$2,000.'); + }); + + test('ignores dollar signs followed by digits (money), but keeps valid math $x + y$', () => { + const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.'; + const output = preprocessLaTeX(input); + + expect(output).toBe('I have \\$10, \\$3.99 and $x + y$ and $100x$. The amount is \\$2,000.'); + }); + + test('handles real-world word problems with amounts and no math delimiters', () => { + const input = + 'Emma buys 2 cupcakes for $3 each and 1 cookie for $1.50. How much money does she spend in total?'; + const output = preprocessLaTeX(input); + + expect(output).toBe( + 'Emma buys 2 cupcakes for \\$3 each and 1 cookie for \\$1.50. How much money does she spend in total?' + ); + }); + + test('handles decimal amounts in word problem correctly', () => { + const input = + 'Maria has $20. She buys a notebook for $4.75 and a pack of pencils for $3.25. How much change does she receive?'; + const output = preprocessLaTeX(input); + + expect(output).toBe( + 'Maria has \\$20. She buys a notebook for \\$4.75 and a pack of pencils for \\$3.25. How much change does she receive?' + ); + }); + + test('preserves display math with surrounding non-ASCII text', () => { + const input = `1 kg の質量は + \\[ + E = (1\\ \\text{kg}) \\times (3.0 \\times 10^8\\ \\text{m/s})^2 \\approx 9.0 \\times 10^{16}\\ \\text{J} + \\] + というエネルギーに相当します。これは約 21 百万トンの TNT が爆発したときのエネルギーに匹敵します。`; + const output = preprocessLaTeX(input); + + expect(output).toBe( + `1 kg の質量は + $$ + E = (1\\ \\text{kg}) \\times (3.0 \\times 10^8\\ \\text{m/s})^2 \\approx 9.0 \\times 10^{16}\\ \\text{J} + $$ + というエネルギーに相当します。これは約 21 百万トンの TNT が爆発したときのエネルギーに匹敵します。` + ); + }); + + test('LaTeX-spacer preceded by backslash', () => { + const input = `\\[ +\\boxed{ +\\begin{aligned} +N_{\\text{att}}^{\\text{(MHA)}} &= +h \\bigl[\\, d_{\\text{model}}\\;d_{k} + d_{\\text{model}}\\;d_{v}\\, \\bigr] && (\\text{Q,K,V の重み})\\\\ +&\\quad+ h(d_{k}+d_{k}+d_{v}) && (\\text{バイアス Q,K,V)}\\\\[4pt] +&\\quad+ (h d_{v})\\, d_{\\text{model}} && (\\text{出力射影 }W^{O})\\\\ +&\\quad+ d_{\\text{model}} && (\\text{バイアス }b^{O}) +\\end{aligned}} +\\]`; + const output = preprocessLaTeX(input); + expect(output).toBe( + `$$ +\\boxed{ +\\begin{aligned} +N_{\\text{att}}^{\\text{(MHA)}} &= +h \\bigl[\\, d_{\\text{model}}\\;d_{k} + d_{\\text{model}}\\;d_{v}\\, \\bigr] && (\\text{Q,K,V の重み})\\\\ +&\\quad+ h(d_{k}+d_{k}+d_{v}) && (\\text{バイアス Q,K,V)}\\\\[4pt] +&\\quad+ (h d_{v})\\, d_{\\text{model}} && (\\text{出力射影 }W^{O})\\\\ +&\\quad+ d_{\\text{model}} && (\\text{バイアス }b^{O}) +\\end{aligned}} +$$` + ); + }); + + test('converts \\[ ... \\] even when preceded by text without space', () => { + const input = 'Some line ...\nAlgebra: \\[x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}\\]'; + const output = preprocessLaTeX(input); + + expect(output).toBe( + 'Some line ...\nAlgebra: \n$$x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}$$\n' + ); + }); + + test('converts \\[ ... \\] in table-cells', () => { + const input = `| ID | Expression |\n| #1 | \\[ + x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a} +\\] |`; + const output = preprocessLaTeX(input); + + expect(output).toBe( + '| ID | Expression |\n| #1 | $x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}$ |' + ); + }); + + test('escapes isolated $ before digits ($5 → \\$5), but not valid math', () => { + const input = 'This costs $5 and this is math $x^2$. $100 is money.'; + const output = preprocessLaTeX(input); + + expect(output).toBe('This costs \\$5 and this is math $x^2$. \\$100 is money.'); + // Note: Since $x^2$ is detected as valid LaTeX, it's preserved. + // $5 becomes \$5 only *after* real math is masked — but here it's correct because the masking logic avoids treating $5 as math. + }); + + test('display with LaTeX-line-breaks', () => { + const input = String.raw`- Algebraic topology, Homotopy Groups of $\mathbb{S}^3$: +$$\pi_n(\mathbb{S}^3) = \begin{cases} +\mathbb{Z} & n = 3 \\ +0 & n > 3, n \neq 4 \\ +\mathbb{Z}_2 & n = 4 \\ +\end{cases}$$`; + const output = preprocessLaTeX(input); + // If the formula contains '\\' the $$-delimiters should be in their own line. + expect(output).toBe(`- Algebraic topology, Homotopy Groups of $\\mathbb{S}^3$: +$$\n\\pi_n(\\mathbb{S}^3) = \\begin{cases} +\\mathbb{Z} & n = 3 \\\\ +0 & n > 3, n \\neq 4 \\\\ +\\mathbb{Z}_2 & n = 4 \\\\ +\\end{cases}\n$$`); + }); + + test('handles mhchem notation safely if present', () => { + const input = 'Chemical reaction: \\( \\ce{H2O} \\) and $\\ce{CO2}$'; + const output = preprocessLaTeX(input); + + expect(output).toBe('Chemical reaction: $ \\ce{H2O} $ and $\\ce{CO2}$'); + }); + + test('preserves code blocks', () => { + const input = 'Inline code: `sum $total` and block:\n```\ndollar $amount\n```\nEnd.'; + const output = preprocessLaTeX(input); + + expect(output).toBe(input); // Code blocks prevent misinterpretation + }); + + test('preserves backslash parentheses in code blocks (GitHub issue)', () => { + const input = '```python\nfoo = "\\(bar\\)"\n```'; + const output = preprocessLaTeX(input); + + expect(output).toBe(input); // Code blocks should not have LaTeX conversion applied + }); + + test('preserves backslash brackets in code blocks', () => { + const input = '```python\nfoo = "\\[bar\\]"\n```'; + const output = preprocessLaTeX(input); + + expect(output).toBe(input); // Code blocks should not have LaTeX conversion applied + }); + + test('preserves backslash parentheses in inline code', () => { + const input = 'Use `foo = "\\(bar\\)"` in your code.'; + const output = preprocessLaTeX(input); + + expect(output).toBe(input); + }); + + test('escape backslash in mchem ce', () => { + const input = 'mchem ce:\n$\\ce{2H2(g) + O2(g) -> 2H2O(l)}$'; + const output = preprocessLaTeX(input); + + // mhchem-escape would insert a backslash here. + expect(output).toBe('mchem ce:\n$\\ce{2H2(g) + O2(g) -> 2H2O(l)}$'); + }); + + test('escape backslash in mchem pu', () => { + const input = 'mchem pu:\n$\\pu{-572 kJ mol^{-1}}$'; + const output = preprocessLaTeX(input); + + // mhchem-escape would insert a backslash here. + expect(output).toBe('mchem pu:\n$\\pu{-572 kJ mol^{-1}}$'); + }); + + test('LaTeX in blockquotes with display math', () => { + const input = + '> **Definition (limit):** \n> \\[\n> \\lim_{x\\to a} f(x) = L\n> \\]\n> means that as \\(x\\) gets close to \\(a\\).'; + const output = preprocessLaTeX(input); + + // Blockquote markers should be preserved, LaTeX should be converted + expect(output).toContain('> **Definition (limit):**'); + expect(output).toContain('$$'); + expect(output).toContain('$x$'); + expect(output).not.toContain('\\['); + expect(output).not.toContain('\\]'); + expect(output).not.toContain('\\('); + expect(output).not.toContain('\\)'); + }); + + test('LaTeX in blockquotes with inline math', () => { + const input = + "> The derivative \\(f'(x)\\) at point \\(x=a\\) measures slope.\n> Formula: \\(f'(a)=\\lim_{h\\to 0}\\frac{f(a+h)-f(a)}{h}\\)"; + const output = preprocessLaTeX(input); + + // Blockquote markers should be preserved, inline LaTeX converted to $...$ + expect(output).toContain("> The derivative $f'(x)$ at point $x=a$ measures slope."); + expect(output).toContain("> Formula: $f'(a)=\\lim_{h\\to 0}\\frac{f(a+h)-f(a)}{h}$"); + }); + + test('Mixed content with blockquotes and regular text', () => { + const input = + 'Regular text with \\(x^2\\).\n\n> Quote with \\(y^2\\).\n\nMore text with \\(z^2\\).'; + const output = preprocessLaTeX(input); + + // All LaTeX should be converted, blockquote markers preserved + expect(output).toBe('Regular text with $x^2$.\n\n> Quote with $y^2$.\n\nMore text with $z^2$.'); + }); +}); diff --git a/tools/ui/tests/unit/mcp-service.test.ts b/tools/ui/tests/unit/mcp-service.test.ts new file mode 100644 index 000000000..afd3bdd5c --- /dev/null +++ b/tools/ui/tests/unit/mcp-service.test.ts @@ -0,0 +1,252 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client'; +import { MCPService } from '$lib/services/mcp.service'; +import { MCPConnectionPhase, MCPTransportType } from '$lib/enums'; +import type { MCPConnectionLog, MCPServerConfig } from '$lib/types'; + +type DiagnosticFetchFactory = ( + serverName: string, + config: MCPServerConfig, + baseInit: RequestInit, + targetUrl: URL, + useProxy: boolean, + onLog?: (log: MCPConnectionLog) => void +) => { fetch: typeof fetch; disable: () => void }; + +const createDiagnosticFetch = ( + config: MCPServerConfig, + onLog?: (log: MCPConnectionLog) => void, + baseInit: RequestInit = {} +) => + ( + MCPService as unknown as { createDiagnosticFetch: DiagnosticFetchFactory } + ).createDiagnosticFetch('test-server', config, baseInit, new URL(config.url), false, onLog); + +describe('MCPService', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('stops transport phase logging after handshake diagnostics are disabled', async () => { + const logs: MCPConnectionLog[] = []; + const response = new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' } + }); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); + + const config: MCPServerConfig = { + url: 'https://example.com/mcp', + transport: MCPTransportType.STREAMABLE_HTTP + }; + + const controller = createDiagnosticFetch(config, (log) => logs.push(log)); + + await controller.fetch(config.url, { method: 'POST', body: '{}' }); + expect(logs).toHaveLength(2); + expect(logs.every((log) => log.message.includes('https://example.com/mcp'))).toBe(true); + + controller.disable(); + await controller.fetch(config.url, { method: 'POST', body: '{}' }); + + expect(logs).toHaveLength(2); + }); + + it('redacts all configured custom headers in diagnostic request logs', async () => { + const logs: MCPConnectionLog[] = []; + const response = new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' } + }); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); + + const config: MCPServerConfig = { + url: 'https://example.com/mcp', + transport: MCPTransportType.STREAMABLE_HTTP, + headers: { + 'x-auth-token': 'secret-token', + 'x-vendor-api-key': 'secret-key' + } + }; + + const controller = createDiagnosticFetch(config, (log) => logs.push(log), { + headers: config.headers + }); + + await controller.fetch(config.url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}' + }); + + expect(logs).toHaveLength(2); + expect(logs[0].details).toMatchObject({ + request: { + headers: { + 'x-auth-token': '[redacted]', + 'x-vendor-api-key': '[redacted]', + 'content-type': 'application/json' + } + } + }); + }); + + it('partially redacts mcp-session-id in diagnostic request and response logs', async () => { + const logs: MCPConnectionLog[] = []; + const response = new Response('{}', { + status: 200, + headers: { + 'content-type': 'application/json', + 'mcp-session-id': 'session-response-67890' + } + }); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); + + const config: MCPServerConfig = { + url: 'https://example.com/mcp', + transport: MCPTransportType.STREAMABLE_HTTP + }; + + const controller = createDiagnosticFetch(config, (log) => logs.push(log)); + + await controller.fetch(config.url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'mcp-session-id': 'session-request-12345' + }, + body: '{}' + }); + + expect(logs).toHaveLength(2); + expect(logs[0].details).toMatchObject({ + request: { + headers: { + 'content-type': 'application/json', + 'mcp-session-id': '....12345' + } + } + }); + expect(logs[1].details).toMatchObject({ + response: { + headers: { + 'content-type': 'application/json', + 'mcp-session-id': '....67890' + } + } + }); + }); + + it('extracts JSON-RPC methods without logging the raw request body', async () => { + const logs: MCPConnectionLog[] = []; + const response = new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' } + }); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); + + const config: MCPServerConfig = { + url: 'https://example.com/mcp', + transport: MCPTransportType.STREAMABLE_HTTP + }; + + const controller = createDiagnosticFetch(config, (log) => logs.push(log)); + + await controller.fetch(config.url, { + method: 'POST', + body: JSON.stringify([ + { jsonrpc: '2.0', id: 1, method: 'initialize' }, + { jsonrpc: '2.0', method: 'notifications/initialized' } + ]) + }); + + expect(logs[0].details).toMatchObject({ + request: { + method: 'POST', + body: { + kind: 'string', + size: expect.any(Number) + }, + jsonRpcMethods: ['initialize', 'notifications/initialized'] + } + }); + }); + + it('adds a CORS hint to Failed to fetch diagnostic log messages', async () => { + const logs: MCPConnectionLog[] = []; + const fetchError = new TypeError('Failed to fetch'); + + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(fetchError)); + + const config: MCPServerConfig = { + url: 'http://localhost:8000/mcp', + transport: MCPTransportType.STREAMABLE_HTTP + }; + + const controller = createDiagnosticFetch(config, (log) => logs.push(log)); + + await expect(controller.fetch(config.url, { method: 'POST', body: '{}' })).rejects.toThrow( + 'Failed to fetch' + ); + + expect(logs).toHaveLength(2); + expect(logs[1].message).toBe( + 'HTTP POST http://localhost:8000/mcp failed: Failed to fetch (check CORS?)' + ); + }); + + it('detaches phase error logging after the initialize handshake completes', async () => { + const phaseLogs: Array<{ phase: MCPConnectionPhase; log: MCPConnectionLog }> = []; + const stopPhaseLogging = vi.fn(); + let emitClientError: ((error: Error) => void) | undefined; + + vi.spyOn(MCPService, 'createTransport').mockReturnValue({ + transport: {} as never, + type: MCPTransportType.WEBSOCKET, + stopPhaseLogging + }); + vi.spyOn(MCPService, 'listTools').mockResolvedValue([]); + vi.spyOn(Client.prototype, 'getServerVersion').mockReturnValue(undefined); + vi.spyOn(Client.prototype, 'getServerCapabilities').mockReturnValue(undefined); + vi.spyOn(Client.prototype, 'getInstructions').mockReturnValue(undefined); + vi.spyOn(Client.prototype, 'connect').mockImplementation(async function (this: Client) { + emitClientError = (error: Error) => this.onerror?.(error); + this.onerror?.(new Error('handshake protocol error')); + }); + + await MCPService.connect( + 'test-server', + { + url: 'ws://example.com/mcp', + transport: MCPTransportType.WEBSOCKET + }, + undefined, + undefined, + (phase, log) => phaseLogs.push({ phase, log }) + ); + + expect(stopPhaseLogging).toHaveBeenCalledTimes(1); + expect( + phaseLogs.filter( + ({ phase, log }) => + phase === MCPConnectionPhase.ERROR && + log.message === 'Protocol error: handshake protocol error' + ) + ).toHaveLength(1); + + emitClientError?.(new Error('runtime protocol error')); + + expect( + phaseLogs.filter( + ({ phase, log }) => + phase === MCPConnectionPhase.ERROR && + log.message === 'Protocol error: runtime protocol error' + ) + ).toHaveLength(0); + }); +}); diff --git a/tools/ui/tests/unit/model-id-parser.test.ts b/tools/ui/tests/unit/model-id-parser.test.ts new file mode 100644 index 000000000..3c2937d35 --- /dev/null +++ b/tools/ui/tests/unit/model-id-parser.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from 'vitest'; +import { ModelsService } from '$lib/services/models.service'; + +const { parseModelId } = ModelsService; + +describe('parseModelId', () => { + it('handles unknown patterns correctly', () => { + expect(parseModelId('model-name-1')).toStrictEqual({ + activatedParams: null, + modelName: 'model-name-1', + orgName: null, + params: null, + quantization: null, + raw: 'model-name-1', + tags: [] + }); + + expect(parseModelId('org/model-name-2')).toStrictEqual({ + activatedParams: null, + modelName: 'model-name-2', + orgName: 'org', + params: null, + quantization: null, + raw: 'org/model-name-2', + tags: [] + }); + }); + + it('extracts model parameters correctly', () => { + expect(parseModelId('model-100B-BF16')).toMatchObject({ params: '100B' }); + expect(parseModelId('model-100B:Q4_K_M')).toMatchObject({ params: '100B' }); + }); + + it('extracts model parameters correctly in lowercase', () => { + expect(parseModelId('model-100b-bf16')).toMatchObject({ params: '100B' }); + expect(parseModelId('model-100b:q4_k_m')).toMatchObject({ params: '100B' }); + }); + + it('extracts activated parameters correctly', () => { + expect(parseModelId('model-100B-A10B-BF16')).toMatchObject({ activatedParams: 'A10B' }); + expect(parseModelId('model-100B-A10B:Q4_K_M')).toMatchObject({ activatedParams: 'A10B' }); + }); + + it('extracts activated parameters correctly in lowercase', () => { + expect(parseModelId('model-100b-a10b-bf16')).toMatchObject({ activatedParams: 'A10B' }); + expect(parseModelId('model-100b-a10b:q4_k_m')).toMatchObject({ activatedParams: 'A10B' }); + }); + + it('extracts quantization correctly', () => { + // Dash-separated quantization + expect(parseModelId('model-100B-UD-IQ1_S')).toMatchObject({ quantization: 'UD-IQ1_S' }); + expect(parseModelId('model-100B-IQ4_XS')).toMatchObject({ quantization: 'IQ4_XS' }); + expect(parseModelId('model-100B-Q4_K_M')).toMatchObject({ quantization: 'Q4_K_M' }); + expect(parseModelId('model-100B-Q8_0')).toMatchObject({ quantization: 'Q8_0' }); + expect(parseModelId('model-100B-UD-Q8_K_XL')).toMatchObject({ quantization: 'UD-Q8_K_XL' }); + expect(parseModelId('model-100B-F16')).toMatchObject({ quantization: 'F16' }); + expect(parseModelId('model-100B-BF16')).toMatchObject({ quantization: 'BF16' }); + expect(parseModelId('model-100B-MXFP4')).toMatchObject({ quantization: 'MXFP4' }); + + // Colon-separated quantization + expect(parseModelId('model-100B:UD-IQ1_S')).toMatchObject({ quantization: 'UD-IQ1_S' }); + expect(parseModelId('model-100B:IQ4_XS')).toMatchObject({ quantization: 'IQ4_XS' }); + expect(parseModelId('model-100B:Q4_K_M')).toMatchObject({ quantization: 'Q4_K_M' }); + expect(parseModelId('model-100B:Q8_0')).toMatchObject({ quantization: 'Q8_0' }); + expect(parseModelId('model-100B:UD-Q8_K_XL')).toMatchObject({ quantization: 'UD-Q8_K_XL' }); + expect(parseModelId('model-100B:F16')).toMatchObject({ quantization: 'F16' }); + expect(parseModelId('model-100B:BF16')).toMatchObject({ quantization: 'BF16' }); + expect(parseModelId('model-100B:MXFP4')).toMatchObject({ quantization: 'MXFP4' }); + + // Dot-separated quantization + expect(parseModelId('nomic-embed-text-v2-moe.Q4_K_M')).toMatchObject({ + quantization: 'Q4_K_M' + }); + }); + + it('extracts additional tags correctly', () => { + expect(parseModelId('model-100B-foobar-Q4_K_M')).toMatchObject({ tags: ['foobar'] }); + expect(parseModelId('model-100B-A10B-foobar-1M-BF16')).toMatchObject({ + tags: ['foobar', '1M'] + }); + expect(parseModelId('model-100B-1M-foobar:UD-Q8_K_XL')).toMatchObject({ + tags: ['1M', 'foobar'] + }); + }); + + it('filters out container format segments from tags', () => { + expect(parseModelId('model-100B-GGUF-Instruct-BF16')).toMatchObject({ + tags: ['Instruct'] + }); + expect(parseModelId('model-100B-GGML-Instruct:Q4_K_M')).toMatchObject({ + tags: ['Instruct'] + }); + }); + + it('handles real-world examples correctly', () => { + expect(parseModelId('meta-llama/Llama-3.1-8B')).toStrictEqual({ + activatedParams: null, + modelName: 'Llama-3.1', + orgName: 'meta-llama', + params: '8B', + quantization: null, + raw: 'meta-llama/Llama-3.1-8B', + tags: [] + }); + + expect(parseModelId('openai/gpt-oss-120b-MXFP4')).toStrictEqual({ + activatedParams: null, + modelName: 'gpt-oss', + orgName: 'openai', + params: '120B', + quantization: 'MXFP4', + raw: 'openai/gpt-oss-120b-MXFP4', + tags: [] + }); + + expect(parseModelId('openai/gpt-oss-20b:Q4_K_M')).toStrictEqual({ + activatedParams: null, + modelName: 'gpt-oss', + orgName: 'openai', + params: '20B', + quantization: 'Q4_K_M', + raw: 'openai/gpt-oss-20b:Q4_K_M', + tags: [] + }); + + expect(parseModelId('Qwen/Qwen3-Coder-30B-A3B-Instruct-1M-BF16')).toStrictEqual({ + activatedParams: 'A3B', + modelName: 'Qwen3-Coder', + orgName: 'Qwen', + params: '30B', + quantization: 'BF16', + raw: 'Qwen/Qwen3-Coder-30B-A3B-Instruct-1M-BF16', + tags: ['Instruct', '1M'] + }); + }); + + it('handles real-world examples with quantization in segments', () => { + expect(parseModelId('meta-llama/Llama-4-Scout-17B-16E-Instruct-Q4_K_M')).toStrictEqual({ + activatedParams: null, + modelName: 'Llama-4-Scout', + orgName: 'meta-llama', + params: '17B', + quantization: 'Q4_K_M', + raw: 'meta-llama/Llama-4-Scout-17B-16E-Instruct-Q4_K_M', + tags: ['16E', 'Instruct'] + }); + + expect(parseModelId('MiniMaxAI/MiniMax-M2-IQ4_XS')).toStrictEqual({ + activatedParams: null, + modelName: 'MiniMax-M2', + orgName: 'MiniMaxAI', + params: null, + quantization: 'IQ4_XS', + raw: 'MiniMaxAI/MiniMax-M2-IQ4_XS', + tags: [] + }); + + expect(parseModelId('MiniMaxAI/MiniMax-M2-UD-Q3_K_XL')).toStrictEqual({ + activatedParams: null, + modelName: 'MiniMax-M2', + orgName: 'MiniMaxAI', + params: null, + quantization: 'UD-Q3_K_XL', + raw: 'MiniMaxAI/MiniMax-M2-UD-Q3_K_XL', + tags: [] + }); + + expect(parseModelId('mistralai/Devstral-2-123B-Instruct-2512-Q4_K_M')).toStrictEqual({ + activatedParams: null, + modelName: 'Devstral-2', + orgName: 'mistralai', + params: '123B', + quantization: 'Q4_K_M', + raw: 'mistralai/Devstral-2-123B-Instruct-2512-Q4_K_M', + tags: ['Instruct', '2512'] + }); + + expect(parseModelId('mistralai/Devstral-Small-2-24B-Instruct-2512-Q8_0')).toStrictEqual({ + activatedParams: null, + modelName: 'Devstral-Small-2', + orgName: 'mistralai', + params: '24B', + quantization: 'Q8_0', + raw: 'mistralai/Devstral-Small-2-24B-Instruct-2512-Q8_0', + tags: ['Instruct', '2512'] + }); + + expect(parseModelId('noctrex/GLM-4.7-Flash-MXFP4_MOE')).toStrictEqual({ + activatedParams: null, + modelName: 'GLM-4.7-Flash', + orgName: 'noctrex', + params: null, + quantization: 'MXFP4_MOE', + raw: 'noctrex/GLM-4.7-Flash-MXFP4_MOE', + tags: [] + }); + + expect(parseModelId('Qwen/Qwen3-Coder-Next-Q4_K_M')).toStrictEqual({ + activatedParams: null, + modelName: 'Qwen3-Coder-Next', + orgName: 'Qwen', + params: null, + quantization: 'Q4_K_M', + raw: 'Qwen/Qwen3-Coder-Next-Q4_K_M', + tags: [] + }); + + expect(parseModelId('openai/gpt-oss-120b-Q4_K_M')).toStrictEqual({ + activatedParams: null, + modelName: 'gpt-oss', + orgName: 'openai', + params: '120B', + quantization: 'Q4_K_M', + raw: 'openai/gpt-oss-120b-Q4_K_M', + tags: [] + }); + + expect(parseModelId('openai/gpt-oss-20b-F16')).toStrictEqual({ + activatedParams: null, + modelName: 'gpt-oss', + orgName: 'openai', + params: '20B', + quantization: 'F16', + raw: 'openai/gpt-oss-20b-F16', + tags: [] + }); + + expect(parseModelId('nomic-embed-text-v2-moe.Q4_K_M')).toStrictEqual({ + activatedParams: null, + modelName: 'nomic-embed-text-v2-moe', + orgName: null, + params: null, + quantization: 'Q4_K_M', + raw: 'nomic-embed-text-v2-moe.Q4_K_M', + tags: [] + }); + }); + + it('handles ambiguous model names', () => { + // Qwen3.5 Instruct vs Thinking — tags should distinguish them + expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Instruct')).toMatchObject({ + modelName: 'Qwen3.5', + params: '30B', + activatedParams: 'A3B', + tags: ['Instruct'] + }); + + expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Thinking')).toMatchObject({ + modelName: 'Qwen3.5', + params: '30B', + activatedParams: 'A3B', + tags: ['Thinking'] + }); + + // Dot-separated quantization with variant suffixes + expect(parseModelId('gemma-3-27b-it-heretic-v2.Q8_0')).toMatchObject({ + modelName: 'gemma-3', + params: '27B', + quantization: 'Q8_0', + tags: ['it', 'heretic', 'v2'] + }); + + expect(parseModelId('gemma-3-27b-it.Q8_0')).toMatchObject({ + modelName: 'gemma-3', + params: '27B', + quantization: 'Q8_0', + tags: ['it'] + }); + }); +}); diff --git a/tools/ui/tests/unit/model-names.test.ts b/tools/ui/tests/unit/model-names.test.ts new file mode 100644 index 000000000..40c5a0e3a --- /dev/null +++ b/tools/ui/tests/unit/model-names.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { isValidModelName, normalizeModelName } from '$lib/utils/model-names'; + +describe('normalizeModelName', () => { + it('preserves Hugging Face org/model format (single slash)', () => { + // Single slash is treated as Hugging Face format and preserved + expect(normalizeModelName('meta-llama/Llama-3.1-8B')).toBe('meta-llama/Llama-3.1-8B'); + expect(normalizeModelName('models/model-name-1')).toBe('models/model-name-1'); + }); + + it('extracts filename from multi-segment paths', () => { + // Multiple slashes -> extract just the filename + expect(normalizeModelName('path/to/model/model-name-2')).toBe('model-name-2'); + expect(normalizeModelName('/absolute/path/to/model')).toBe('model'); + }); + + it('extracts filename from backslash paths', () => { + expect(normalizeModelName('C\\Models\\model-name-1')).toBe('model-name-1'); + expect(normalizeModelName('path\\to\\model\\model-name-2')).toBe('model-name-2'); + }); + + it('handles mixed path separators', () => { + expect(normalizeModelName('path/to\\model/model-name-2')).toBe('model-name-2'); + }); + + it('returns simple names as-is', () => { + expect(normalizeModelName('simple-model')).toBe('simple-model'); + expect(normalizeModelName('model-name-2')).toBe('model-name-2'); + }); + + it('trims whitespace', () => { + expect(normalizeModelName(' model-name ')).toBe('model-name'); + }); + + it('returns empty string for empty input', () => { + expect(normalizeModelName('')).toBe(''); + expect(normalizeModelName(' ')).toBe(''); + }); +}); + +describe('isValidModelName', () => { + it('returns true for valid names', () => { + expect(isValidModelName('model')).toBe(true); + expect(isValidModelName('path/to/model.bin')).toBe(true); + }); + + it('returns false for empty values', () => { + expect(isValidModelName('')).toBe(false); + expect(isValidModelName(' ')).toBe(false); + }); +}); diff --git a/tools/ui/tests/unit/reasoning-context.test.ts b/tools/ui/tests/unit/reasoning-context.test.ts new file mode 100644 index 000000000..b448974a3 --- /dev/null +++ b/tools/ui/tests/unit/reasoning-context.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { MessageRole } from '$lib/enums'; + +/** + * Tests for the new reasoning content handling. + * In the new architecture, reasoning content is stored in a dedicated + * `reasoningContent` field on DatabaseMessage, not embedded in content with tags. + * The API sends it as `reasoning_content` on ApiChatMessageData. + */ + +describe('reasoning content in new structured format', () => { + it('reasoning is stored as separate field, not in content', () => { + // Simulate what the new chat store does + const message = { + content: 'The answer is 4.', + reasoningContent: 'Let me think: 2+2=4, basic arithmetic.' + }; + + // Content should be clean + expect(message.content).not.toContain('<<<'); + expect(message.content).toBe('The answer is 4.'); + + // Reasoning in dedicated field + expect(message.reasoningContent).toBe('Let me think: 2+2=4, basic arithmetic.'); + }); + + it('convertDbMessageToApiChatMessageData includes reasoning_content', () => { + // Simulate the conversion logic + const dbMessage = { + role: MessageRole.ASSISTANT, + content: 'The answer is 4.', + reasoningContent: 'Let me think: 2+2=4, basic arithmetic.' + }; + + const apiMessage: Record = { + role: dbMessage.role, + content: dbMessage.content + }; + if (dbMessage.reasoningContent) { + apiMessage.reasoning_content = dbMessage.reasoningContent; + } + + expect(apiMessage.content).toBe('The answer is 4.'); + expect(apiMessage.reasoning_content).toBe('Let me think: 2+2=4, basic arithmetic.'); + // No internal tags leak into either field + expect(apiMessage.content).not.toContain('<<<'); + expect(apiMessage.reasoning_content).not.toContain('<<<'); + }); + + it('API message excludes reasoning when excludeReasoningFromContext is true', () => { + const dbMessage = { + role: MessageRole.ASSISTANT, + content: 'The answer is 4.', + reasoningContent: 'internal thinking' + }; + + const excludeReasoningFromContext = true; + + const apiMessage: Record = { + role: dbMessage.role, + content: dbMessage.content + }; + if (!excludeReasoningFromContext && dbMessage.reasoningContent) { + apiMessage.reasoning_content = dbMessage.reasoningContent; + } + + expect(apiMessage.content).toBe('The answer is 4.'); + expect(apiMessage.reasoning_content).toBeUndefined(); + }); + + it('handles messages with no reasoning', () => { + const dbMessage = { + role: MessageRole.ASSISTANT, + content: 'No reasoning here.', + reasoningContent: undefined + }; + + const apiMessage: Record = { + role: dbMessage.role, + content: dbMessage.content + }; + if (dbMessage.reasoningContent) { + apiMessage.reasoning_content = dbMessage.reasoningContent; + } + + expect(apiMessage.content).toBe('No reasoning here.'); + expect(apiMessage.reasoning_content).toBeUndefined(); + }); +}); diff --git a/tools/ui/tests/unit/redact.test.ts b/tools/ui/tests/unit/redact.test.ts new file mode 100644 index 000000000..750296c53 --- /dev/null +++ b/tools/ui/tests/unit/redact.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { redactValue } from '$lib/utils/redact'; + +describe('redactValue', () => { + it('returns [redacted] by default', () => { + expect(redactValue('secret-token')).toBe('[redacted]'); + }); + + it('shows last N characters when showLastChars is provided', () => { + expect(redactValue('session-abc12', 5)).toBe('....abc12'); + }); + + it('handles value shorter than showLastChars', () => { + expect(redactValue('ab', 5)).toBe('....ab'); + }); + + it('returns [redacted] when showLastChars is 0', () => { + expect(redactValue('secret', 0)).toBe('[redacted]'); + }); +}); diff --git a/tools/ui/tests/unit/request-helpers.test.ts b/tools/ui/tests/unit/request-helpers.test.ts new file mode 100644 index 000000000..c43252876 --- /dev/null +++ b/tools/ui/tests/unit/request-helpers.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { + getRequestUrl, + getRequestMethod, + getRequestBody, + summarizeRequestBody, + formatDiagnosticErrorMessage, + extractJsonRpcMethods +} from '$lib/utils/request-helpers'; + +describe('getRequestUrl', () => { + it('returns a plain string input as-is', () => { + expect(getRequestUrl('https://example.com/mcp')).toBe('https://example.com/mcp'); + }); + + it('returns href from a URL object', () => { + expect(getRequestUrl(new URL('https://example.com/mcp'))).toBe('https://example.com/mcp'); + }); + + it('returns url from a Request object', () => { + const req = new Request('https://example.com/mcp'); + expect(getRequestUrl(req)).toBe('https://example.com/mcp'); + }); +}); + +describe('getRequestMethod', () => { + it('prefers method from init', () => { + expect(getRequestMethod('https://example.com', { method: 'POST' })).toBe('POST'); + }); + + it('falls back to Request.method', () => { + const req = new Request('https://example.com', { method: 'PUT' }); + expect(getRequestMethod(req)).toBe('PUT'); + }); + + it('falls back to baseInit.method', () => { + expect(getRequestMethod('https://example.com', undefined, { method: 'DELETE' })).toBe('DELETE'); + }); + + it('defaults to GET', () => { + expect(getRequestMethod('https://example.com')).toBe('GET'); + }); +}); + +describe('getRequestBody', () => { + it('returns body from init', () => { + expect(getRequestBody('https://example.com', { body: 'payload' })).toBe('payload'); + }); + + it('returns undefined when no body is present', () => { + expect(getRequestBody('https://example.com')).toBeUndefined(); + }); +}); + +describe('summarizeRequestBody', () => { + it('returns empty for null', () => { + expect(summarizeRequestBody(null)).toEqual({ kind: 'empty' }); + }); + + it('returns empty for undefined', () => { + expect(summarizeRequestBody(undefined)).toEqual({ kind: 'empty' }); + }); + + it('returns string kind with size', () => { + expect(summarizeRequestBody('hello')).toEqual({ kind: 'string', size: 5 }); + }); + + it('returns blob kind with size', () => { + const blob = new Blob(['abc']); + expect(summarizeRequestBody(blob)).toEqual({ kind: 'blob', size: 3 }); + }); + + it('returns formdata kind', () => { + expect(summarizeRequestBody(new FormData())).toEqual({ kind: 'formdata' }); + }); + + it('returns arraybuffer kind with size', () => { + expect(summarizeRequestBody(new ArrayBuffer(8))).toEqual({ kind: 'arraybuffer', size: 8 }); + }); +}); + +describe('formatDiagnosticErrorMessage', () => { + it('appends CORS hint for Failed to fetch', () => { + expect(formatDiagnosticErrorMessage(new TypeError('Failed to fetch'))).toBe( + 'Failed to fetch (check CORS?)' + ); + }); + + it('passes through other error messages unchanged', () => { + expect(formatDiagnosticErrorMessage(new Error('timeout'))).toBe('timeout'); + }); + + it('handles non-Error values', () => { + expect(formatDiagnosticErrorMessage('some string')).toBe('some string'); + }); +}); + +describe('extractJsonRpcMethods', () => { + it('extracts methods from a JSON-RPC array', () => { + const body = JSON.stringify([ + { jsonrpc: '2.0', id: 1, method: 'initialize' }, + { jsonrpc: '2.0', method: 'notifications/initialized' } + ]); + expect(extractJsonRpcMethods(body)).toEqual(['initialize', 'notifications/initialized']); + }); + + it('extracts method from a single JSON-RPC message', () => { + const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + expect(extractJsonRpcMethods(body)).toEqual(['tools/list']); + }); + + it('returns undefined for non-string body', () => { + expect(extractJsonRpcMethods(null)).toBeUndefined(); + expect(extractJsonRpcMethods(undefined)).toBeUndefined(); + }); + + it('returns undefined for invalid JSON', () => { + expect(extractJsonRpcMethods('not json')).toBeUndefined(); + }); + + it('returns undefined when no methods found', () => { + expect(extractJsonRpcMethods(JSON.stringify({ foo: 'bar' }))).toBeUndefined(); + }); +}); diff --git a/tools/ui/tests/unit/sanitize-headers.test.ts b/tools/ui/tests/unit/sanitize-headers.test.ts new file mode 100644 index 000000000..f5a682d86 --- /dev/null +++ b/tools/ui/tests/unit/sanitize-headers.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { sanitizeHeaders } from '$lib/utils/api-headers'; + +describe('sanitizeHeaders', () => { + it('returns empty object for undefined input', () => { + expect(sanitizeHeaders()).toEqual({}); + }); + + it('passes through non-sensitive headers', () => { + const headers = new Headers({ 'content-type': 'application/json', accept: 'text/html' }); + expect(sanitizeHeaders(headers)).toEqual({ + 'content-type': 'application/json', + accept: 'text/html' + }); + }); + + it('redacts known sensitive headers', () => { + const headers = new Headers({ + authorization: 'Bearer secret', + 'x-api-key': 'key-123', + 'content-type': 'application/json' + }); + const result = sanitizeHeaders(headers); + expect(result.authorization).toBe('[redacted]'); + expect(result['x-api-key']).toBe('[redacted]'); + expect(result['content-type']).toBe('application/json'); + }); + + it('partially redacts headers specified in partialRedactHeaders', () => { + const headers = new Headers({ 'mcp-session-id': 'session-12345' }); + const partial = new Map([['mcp-session-id', 5]]); + expect(sanitizeHeaders(headers, undefined, partial)['mcp-session-id']).toBe('....12345'); + }); + + it('fully redacts mcp-session-id when no partialRedactHeaders is given', () => { + const headers = new Headers({ 'mcp-session-id': 'session-12345' }); + expect(sanitizeHeaders(headers)['mcp-session-id']).toBe('[redacted]'); + }); + + it('redacts extra headers provided by the caller', () => { + const headers = new Headers({ + 'x-vendor-key': 'vendor-secret', + 'content-type': 'application/json' + }); + const result = sanitizeHeaders(headers, ['x-vendor-key']); + expect(result['x-vendor-key']).toBe('[redacted]'); + expect(result['content-type']).toBe('application/json'); + }); + + it('handles case-insensitive extra header names', () => { + const headers = new Headers({ 'X-Custom-Token': 'token-value' }); + const result = sanitizeHeaders(headers, ['X-CUSTOM-TOKEN']); + expect(result['x-custom-token']).toBe('[redacted]'); + }); +}); diff --git a/tools/ui/tests/unit/uri-template.test.ts b/tools/ui/tests/unit/uri-template.test.ts new file mode 100644 index 000000000..622127923 --- /dev/null +++ b/tools/ui/tests/unit/uri-template.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { + extractTemplateVariables, + expandTemplate, + isTemplateComplete, + normalizeResourceUri +} from '../../src/lib/utils/uri-template'; +import { URI_TEMPLATE_OPERATORS } from '../../src/lib/constants/uri-template'; + +describe('extractTemplateVariables', () => { + it('extracts simple variables', () => { + const vars = extractTemplateVariables('file:///{path}'); + expect(vars).toEqual([{ name: 'path', operator: '' }]); + }); + + it('extracts multiple variables', () => { + const vars = extractTemplateVariables('db://{schema}/{table}'); + expect(vars).toEqual([ + { name: 'schema', operator: '' }, + { name: 'table', operator: '' } + ]); + }); + + it('extracts variables with operators', () => { + const vars = extractTemplateVariables('http://example.com{+path}'); + expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_OPERATORS.RESERVED }]); + }); + + it('extracts comma-separated variable lists', () => { + const vars = extractTemplateVariables('{x,y,z}'); + expect(vars).toEqual([ + { name: 'x', operator: '' }, + { name: 'y', operator: '' }, + { name: 'z', operator: '' } + ]); + }); + + it('deduplicates variable names', () => { + const vars = extractTemplateVariables('{name}/{name}'); + expect(vars).toEqual([{ name: 'name', operator: '' }]); + }); + + it('handles fragment expansion', () => { + const vars = extractTemplateVariables('http://example.com/page{#section}'); + expect(vars).toEqual([{ name: 'section', operator: URI_TEMPLATE_OPERATORS.FRAGMENT }]); + }); + + it('handles path segment expansion', () => { + const vars = extractTemplateVariables('http://example.com{/path}'); + expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_OPERATORS.PATH_SEGMENT }]); + }); + + it('returns empty array for template without variables', () => { + const vars = extractTemplateVariables('http://example.com/static'); + expect(vars).toEqual([]); + }); + + it('strips explode modifier', () => { + const vars = extractTemplateVariables('{list*}'); + expect(vars).toEqual([{ name: 'list', operator: '' }]); + }); + + it('strips prefix modifier', () => { + const vars = extractTemplateVariables('{value:5}'); + expect(vars).toEqual([{ name: 'value', operator: '' }]); + }); +}); + +describe('expandTemplate', () => { + it('expands simple variable', () => { + const result = expandTemplate('file:///{path}', { path: 'src/main.rs' }); + expect(result).toBe('file:///src%2Fmain.rs'); + }); + + it('expands reserved variable (no encoding)', () => { + const result = expandTemplate('file:///{+path}', { path: 'src/main.rs' }); + expect(result).toBe('file:///src/main.rs'); + }); + + it('expands multiple variables', () => { + const result = expandTemplate('db://{schema}/{table}', { + schema: 'public', + table: 'users' + }); + expect(result).toBe('db://public/users'); + }); + + it('leaves empty for missing variables', () => { + const result = expandTemplate('{missing}', {}); + expect(result).toBe(''); + }); + + it('expands fragment', () => { + const result = expandTemplate('http://example.com/page{#section}', { + section: 'intro' + }); + expect(result).toBe('http://example.com/page#intro'); + }); + + it('expands path segments', () => { + const result = expandTemplate('http://example.com{/path}', { path: 'docs' }); + expect(result).toBe('http://example.com/docs'); + }); + + it('expands query parameters', () => { + const result = expandTemplate('http://example.com{?q}', { q: 'search term' }); + expect(result).toBe('http://example.com?q=search%20term'); + }); + + it('keeps static parts unchanged', () => { + const result = expandTemplate('http://example.com/static', {}); + expect(result).toBe('http://example.com/static'); + }); +}); + +describe('isTemplateComplete', () => { + it('returns true when all variables are filled', () => { + expect(isTemplateComplete('file:///{path}', { path: 'test.txt' })).toBe(true); + }); + + it('returns false when a variable is missing', () => { + expect(isTemplateComplete('db://{schema}/{table}', { schema: 'public' })).toBe(false); + }); + + it('returns false when a variable is empty', () => { + expect(isTemplateComplete('file:///{path}', { path: '' })).toBe(false); + }); + + it('returns false when a variable is whitespace only', () => { + expect(isTemplateComplete('file:///{path}', { path: ' ' })).toBe(false); + }); + + it('returns true for template without variables', () => { + expect(isTemplateComplete('http://example.com/static', {})).toBe(true); + }); + + it('returns true when all multiple variables are filled', () => { + expect(isTemplateComplete('db://{schema}/{table}', { schema: 'public', table: 'users' })).toBe( + true + ); + }); +}); + +describe('normalizeResourceUri', () => { + it('passes through a normal URI unchanged', () => { + expect(normalizeResourceUri('svelte://svelte/$effect.md')).toBe('svelte://svelte/$effect.md'); + }); + + it('normalizes triple-slash URIs from path-style template expansion', () => { + expect(normalizeResourceUri('svelte:///svelte/$effect.md')).toBe('svelte://svelte/$effect.md'); + }); + + it('normalizes quadruple-slash URIs', () => { + expect(normalizeResourceUri('svelte:////svelte/$effect.md')).toBe('svelte://svelte/$effect.md'); + }); + + it('handles file:// URIs', () => { + expect(normalizeResourceUri('file:///home/user/doc.txt')).toBe('file://home/user/doc.txt'); + }); + + it('handles http URIs unchanged', () => { + expect(normalizeResourceUri('http://example.com/path')).toBe('http://example.com/path'); + }); + + it('returns non-URI strings unchanged', () => { + expect(normalizeResourceUri('not-a-uri')).toBe('not-a-uri'); + }); +}); diff --git a/tools/ui/tsconfig.json b/tools/ui/tsconfig.json new file mode 100644 index 000000000..7c585f4db --- /dev/null +++ b/tools/ui/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + }, + "include": [ + ".svelte-kit/ambient.d.ts", + ".svelte-kit/non-ambient.d.ts", + ".svelte-kit/types/**/$types.d.ts", + "vite.config.js", + "vite.config.ts", + "src/**/*.js", + "src/**/*.ts", + "src/**/*.svelte", + "tests/**/*.ts", + "tests/**/*.svelte", + ".storybook/**/*.ts", + ".storybook/**/*.svelte" + ] + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/tools/ui/ui.cpp b/tools/ui/ui.cpp new file mode 100644 index 000000000..d02a62c2c --- /dev/null +++ b/tools/ui/ui.cpp @@ -0,0 +1,7 @@ +#ifdef LLAMA_BUILD_UI +// auto generated files (see README.md for details) +#include "index.html.hpp" +#include "bundle.js.hpp" +#include "bundle.css.hpp" +#include "loading.html.hpp" +#endif diff --git a/tools/ui/ui.h b/tools/ui/ui.h new file mode 100644 index 000000000..6f775ea3a --- /dev/null +++ b/tools/ui/ui.h @@ -0,0 +1,17 @@ +#pragma once + +// TODO @ngxson : refactor, wrap these in a function + +#ifdef LLAMA_BUILD_UI +extern unsigned char index_html[]; +extern unsigned int index_html_len; + +extern unsigned char bundle_js[]; +extern unsigned int bundle_js_len; + +extern unsigned char bundle_css[]; +extern unsigned int bundle_css_len; + +extern unsigned char loading_html[]; +extern unsigned int loading_html_len; +#endif diff --git a/tools/ui/vite.config.ts b/tools/ui/vite.config.ts new file mode 100644 index 000000000..d3db24bf2 --- /dev/null +++ b/tools/ui/vite.config.ts @@ -0,0 +1,105 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +import { defineConfig, searchForWorkspaceRoot } from 'vite'; +import devtoolsJson from 'vite-plugin-devtools-json'; +import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; +import { llamaCppBuildPlugin } from './scripts/vite-plugin-llama-cpp-build'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + 'katex-fonts': resolve('node_modules/katex/dist/fonts') + } + }, + + build: { + assetsInlineLimit: 32000, + chunkSizeWarningLimit: 3072, + minify: true + }, + + css: { + preprocessorOptions: { + scss: { + additionalData: ` + $use-woff2: true; + $use-woff: false; + $use-ttf: false; + ` + } + } + }, + + plugins: [tailwindcss(), sveltekit(), devtoolsJson(), llamaCppBuildPlugin()], + + test: { + projects: [ + { + extends: './vite.config.ts', + test: { + name: 'client', + environment: 'browser', + browser: { + enabled: true, + provider: 'playwright', + instances: [{ browser: 'chromium' }] + }, + include: ['tests/client/**/*.svelte.{test,spec}.{js,ts}'], + setupFiles: ['./vitest-setup-client.ts'] + } + }, + + { + extends: './vite.config.ts', + test: { + name: 'unit', + environment: 'node', + include: ['tests/unit/**/*.{test,spec}.{js,ts}'] + } + }, + + { + extends: './vite.config.ts', + test: { + name: 'ui', + environment: 'browser', + browser: { + enabled: true, + provider: 'playwright', + instances: [{ browser: 'chromium', headless: true }] + }, + include: ['tests/stories/**/*.stories.{js,ts,svelte}'], + setupFiles: ['./.storybook/vitest.setup.ts'] + }, + plugins: [ + storybookTest({ + storybookScript: 'pnpm run storybook --no-open' + }) + ] + } + ] + }, + + server: { + proxy: { + '/v1': 'http://localhost:8080', + '/props': 'http://localhost:8080', + '/models': 'http://localhost:8080', + '/tools': 'http://localhost:8080', + '/slots': 'http://localhost:8080', + '/cors-proxy': 'http://localhost:8080' + }, + headers: { + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Opener-Policy': 'same-origin' + }, + fs: { + allow: [searchForWorkspaceRoot(process.cwd()), resolve(__dirname, 'tests')] + } + } +}); diff --git a/tools/ui/vitest-setup-client.ts b/tools/ui/vitest-setup-client.ts new file mode 100644 index 000000000..570b9f0e1 --- /dev/null +++ b/tools/ui/vitest-setup-client.ts @@ -0,0 +1,2 @@ +/// +///