commit 13c6e7ade6a0f30b1fefc4d7c1bfda69625c90b9
Author: javad
Date: Sat Oct 10 13:34:23 2026 +0330
Initial commit of the Kamidso web UI.
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..7bf06a2
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,13 @@
+node_modules
+dist
+dist-ssr
+.git
+.github
+*.local
+*.log
+npm-debug.log*
+.env
+.env.*
+coverage
+.vite
+.cache
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..dcc84ab
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,8 @@
+# Local dev only. Leave empty so the browser calls /api/kamidso/v1 on this dev server.
+# Vite then proxies that path. Production builds read .env.production instead.
+VITE_API_BASE_URL=
+VITE_DEBUG_MODE=true
+
+# Dev server only. Not sent to the browser. Origin only, without /api.
+# start-backend.ps1 listens on 5670; this matches a local uvicorn on 8000.
+API_BASE_URL=http://127.0.0.1:8000
diff --git a/.env.production b/.env.production
new file mode 100644
index 0000000..c0b09dc
--- /dev/null
+++ b/.env.production
@@ -0,0 +1,2 @@
+# Baked into `vite build`. Origin only; the app appends /api/kamidso/v1.
+VITE_API_BASE_URL=https://api.kamidso.com
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..cafd990
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+nginx.prod.conf text eol=lf
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6d86a95
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,34 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Local env. Keep .env.example and .env.production (public build URL).
+.env
+.env.local
+.env.*.local
+
+coverage
+.vite
+.cache
+.DS_Store
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..214c29d
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+registry=https://registry.npmjs.org/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..41b59e2
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,59 @@
+# Build context is frontend/ (Coolify base directory /frontend).
+#
+# Cache contract:
+# 1. npm CLI pin (node:22-alpine ships npm 10.9.9, which crashes with "edgesOut")
+# 2. package.json + package-lock.json -> npm ci
+# 3. Source, then Vite. VITE_API_BASE_URL is applied here so it does not
+# invalidate the install layer.
+# A React/source edit must leave `npm ci` CACHED. A lockfile edit must not.
+
+ARG NODE_IMAGE=node:22-alpine
+ARG NGINX_IMAGE=nginx:1.27-alpine
+
+FROM ${NODE_IMAGE} AS build
+
+# Build-only proxy. Not written into the nginx image.
+ARG HTTP_PROXY
+ARG HTTPS_PROXY
+ARG http_proxy
+ARG https_proxy
+ARG NO_PROXY
+ARG no_proxy
+
+ARG NPM_VERSION=12.2.0
+# Empty keeps https://registry.npmjs.org. Set in Coolify for an Iran mirror.
+ARG NPM_REGISTRY=
+
+WORKDIR /app
+
+RUN --mount=type=cache,target=/root/.npm \
+ if [ -n "${NPM_REGISTRY}" ]; then npm config set registry "${NPM_REGISTRY}"; fi \
+ && npm install -g "npm@${NPM_VERSION}"
+
+COPY package.json package-lock.json ./
+
+RUN --mount=type=cache,target=/root/.npm \
+ if [ -n "${NPM_REGISTRY}" ]; then npm config set registry "${NPM_REGISTRY}"; fi \
+ && npm ci --no-audit --no-fund --allow-remote=all
+
+COPY . .
+
+# Vite inlines this at build time. A runtime env var does not change the bundle.
+# Declared after npm ci so a URL change rebuilds the bundle only.
+ARG VITE_API_BASE_URL=
+ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
+
+RUN npm run build
+
+FROM ${NGINX_IMAGE}
+
+# Local compose keeps nginx.conf (it proxies /api to the backend service).
+# Coolify passes nginx.prod.conf: the SPA is kamidso.com and the API is api.kamidso.com.
+ARG NGINX_CONF=nginx.conf
+COPY ${NGINX_CONF} /etc/nginx/conf.d/default.conf
+COPY --from=build /app/dist /usr/share/nginx/html
+
+EXPOSE 80
+
+HEALTHCHECK --interval=20s --timeout=5s --start-period=10s --retries=5 \
+ CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..4cb947d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 magic.chen
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..1897c69
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,12 @@
+Kamidso Next
+Copyright (c) 2026 Kamidso
+
+This product includes software developed as DB-GPT
+(https://github.com/eosphoros-ai/DB-GPT), licensed under the MIT License.
+
+DB-GPT
+Copyright (c) 2023 magic.chen
+
+See the LICENSE file in this repository for the full MIT license text.
+The upstream copyright and permission notice must be retained in all copies
+or substantial portions of the Software.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b98c0d2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,35 @@
+# Kamidso Frontend
+
+React + Vite SPA for the Kamidso product (Phase 1).
+
+## Stack
+
+- React 19, Vite 6, TypeScript, Tailwind v4
+- UI shell copied from kamidso-mvp; assistant chat core from DB-GPT (SSE, session files, Manus layout)
+- Backend: `/api/kamidso/v1` on Python server (default port 5670)
+
+## Run
+
+```bash
+npm install
+npm run dev
+```
+
+```powershell
+.\scripts\start-frontend.ps1
+```
+
+Open http://localhost:5173 — Vite proxies `/api` and `/images` to `http://127.0.0.1:5670`.
+
+## Routes
+
+| Route | Description |
+|---|---|
+| `/` | Landing |
+| `/login` | Login |
+| `/home` | Home + quick ask |
+| `/assistant` | DB-GPT-style agent workspace |
+| `/databases` | Datasource list |
+| `/knowledge` | Knowledge spaces |
+| `/skills` | Skills list |
+| `/settings` | Account + logout |
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..092408a
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,28 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+
+export default tseslint.config(
+ { ignores: ['dist'] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ['**/*.{ts,tsx}'],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ plugins: {
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ },
+ },
+)
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..8671cc9
--- /dev/null
+++ b/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ Kamidso
+
+
+
+
+
+
+
+
+
+
diff --git a/liara.json b/liara.json
new file mode 100644
index 0000000..386c72e
--- /dev/null
+++ b/liara.json
@@ -0,0 +1,8 @@
+{
+ "app": "kamidso-front",
+ "platform": "react",
+ "port": 80,
+ "react": {
+ "mirror": false
+ }
+}
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..ba83fc9
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,61 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ client_max_body_size 50m;
+
+ gzip on;
+ gzip_types text/css application/javascript application/json image/svg+xml;
+
+ # Docker DNS, so a recreated backend container is picked up without an nginx reload.
+ resolver 127.0.0.11 valid=30s ipv6=off;
+
+ location /api/ {
+ set $backend_upstream backend:5670;
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ }
+
+ location /files/ {
+ set $backend_upstream backend:5670;
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_buffering off;
+ proxy_read_timeout 3600s;
+ }
+
+ location /images/ {
+ set $backend_upstream backend:5670;
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_buffering off;
+ }
+
+ location /health/ {
+ set $backend_upstream backend:5670;
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ }
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/nginx.prod.conf b/nginx.prod.conf
new file mode 100644
index 0000000..8b32bb4
--- /dev/null
+++ b/nginx.prod.conf
@@ -0,0 +1,14 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+
+ gzip on;
+ gzip_types text/css application/javascript application/json image/svg+xml;
+
+ # API, files, and images are served by api.kamidso.com. This host is the SPA only,
+ # so a login cookie is never set on kamidso.com.
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..b6dda77
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,7114 @@
+{
+ "name": "kamidso-frontend",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "kamidso-frontend",
+ "version": "0.0.0",
+ "dependencies": {
+ "@xyflow/react": "^12.8.2",
+ "katex": "^0.16.21",
+ "lucide-react": "^1.31.0",
+ "qrcode": "^1.5.4",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "react-markdown": "^10.1.0",
+ "react-router-dom": "^7.18.2",
+ "react-syntax-highlighter": "^15.6.1",
+ "recharts": "^3.10.1",
+ "rehype-katex": "^7.0.1",
+ "remark-gfm": "^4.0.1",
+ "uuid": "^11.1.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.25.0",
+ "@tailwindcss/vite": "^4.3.3",
+ "@testing-library/dom": "^10.4.2",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.4",
+ "@types/qrcode": "^1.5.6",
+ "@types/react": "^19.1.2",
+ "@types/react-dom": "^19.1.2",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "@types/uuid": "^10.0.0",
+ "@vitejs/plugin-react": "^4.4.1",
+ "eslint": "^9.25.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.19",
+ "globals": "^16.0.0",
+ "jsdom": "^29.1.1",
+ "tailwindcss": "^4.3.3",
+ "typescript": "~5.8.3",
+ "typescript-eslint": "^8.30.1",
+ "vite": "^6.3.5",
+ "vitest": "^4.1.10"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.1",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.1",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.8",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.10.1",
+ "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",
+ "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",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "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",
+ "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.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.3.0",
+ "minimatch": "^3.1.5",
+ "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",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.5",
+ "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",
+ "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",
+ "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/@exodus/bytes": {
+ "version": "1.15.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "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",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.12.0",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
+ "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
+ "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
+ "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
+ "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
+ "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
+ "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
+ "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
+ "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
+ "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
+ "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
+ "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
+ "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
+ "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
+ "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
+ "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
+ "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
+ "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
+ "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
+ "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
+ "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
+ "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
+ "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
+ "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.4",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.4",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "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.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "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.2.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": "1.1.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "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.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.2",
+ "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",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "7.0.1",
+ "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": ">=22",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11",
+ "vitest": ">= 0.32"
+ },
+ "peerDependenciesMeta": {
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.4",
+ "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",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.12",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.5",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/katex": {
+ "version": "0.16.8",
+ "license": "MIT"
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@types/qrcode": {
+ "version": "1.5.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.18",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.4",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/react-syntax-highlighter": {
+ "version": "15.5.13",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "license": "MIT"
+ },
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "license": "MIT"
+ },
+ "node_modules/@types/uuid": {
+ "version": "10.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.66.0",
+ "@typescript-eslint/type-utils": "8.66.0",
+ "@typescript-eslint/utils": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.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.66.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.6",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.66.0",
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.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.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.66.0",
+ "@typescript-eslint/types": "^8.66.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.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.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.66.0",
+ "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.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.0",
+ "@typescript-eslint/utils": "8.66.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.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.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.66.0",
+ "dev": true,
+ "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.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.66.0",
+ "@typescript-eslint/tsconfig-utils": "8.66.0",
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.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.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "10.2.6",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.8.5",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.66.0",
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.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.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.66.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.1",
+ "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.4.0",
+ "license": "ISC"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@xyflow/react": {
+ "version": "12.8.2",
+ "license": "MIT",
+ "dependencies": {
+ "@xyflow/system": "0.0.66",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.66",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "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/ansi-regex": {
+ "version": "5.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "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",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.13",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.18",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.8",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001809",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "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",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "license": "MIT"
+ },
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "license": "MIT"
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/commander": {
+ "version": "8.3.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "license": "MIT"
+ },
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "license": "MIT"
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.403",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "license": "MIT"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-toolkit": {
+ "version": "1.51.0",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks",
+ "tests/types",
+ "tests/browser-compat"
+ ]
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.6",
+ "@eslint/js": "9.39.5",
+ "@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.14.0",
+ "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.5",
+ "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-plugin-react-hooks": {
+ "version": "5.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.4.26",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=8.40"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "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",
+ "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/espree": {
+ "version": "10.4.0",
+ "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/esquery": {
+ "version": "1.7.0",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "license": "MIT"
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fault": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "format": "^0.2.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "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",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "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",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/format": {
+ "version": "0.2.2",
+ "engines": {
+ "node": ">=0.4.x"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/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/gensync": {
+ "version": "1.0.0-beta.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "16.5.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/hast-util-from-dom": {
+ "version": "5.0.1",
+ "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-dom/node_modules/hast-util-parse-selector": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-dom/node_modules/hastscript": {
+ "version": "9.0.1",
+ "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/hast-util-from-html": {
+ "version": "2.0.3",
+ "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",
+ "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/entities": {
+ "version": "6.0.1",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/hast-util-from-html/node_modules/parse5": {
+ "version": "7.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/hast-util-from-parse5": {
+ "version": "8.0.3",
+ "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/hast-util-parse-selector": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-parse5/node_modules/hastscript": {
+ "version": "9.0.1",
+ "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/hast-util-is-element": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-parse-selector": {
+ "version": "2.2.5",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-text": {
+ "version": "4.0.2",
+ "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-whitespace": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hastscript": {
+ "version": "6.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^2.0.0",
+ "comma-separated-tokens": "^1.0.0",
+ "hast-util-parse-selector": "^2.0.0",
+ "property-information": "^5.0.0",
+ "space-separated-tokens": "^1.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hastscript/node_modules/@types/hast": {
+ "version": "2.3.10",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2"
+ }
+ },
+ "node_modules/hastscript/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "license": "MIT"
+ },
+ "node_modules/hastscript/node_modules/comma-separated-tokens": {
+ "version": "1.0.8",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/hastscript/node_modules/property-information": {
+ "version": "5.6.0",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/hastscript/node_modules/space-separated-tokens": {
+ "version": "1.1.5",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/highlight.js": {
+ "version": "10.7.3",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/highlightjs-vue": {
+ "version": "1.0.0",
+ "license": "CC0-1.0"
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/immer": {
+ "version": "11.1.17",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "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",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "license": "MIT"
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.1",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/katex": {
+ "version": "0.16.47",
+ "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",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "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.32.0",
+ "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-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "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.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "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.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "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.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "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.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "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.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "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.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "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.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "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.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "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.32.0",
+ "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/locate-path": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/lowlight": {
+ "version": "1.20.0",
+ "license": "MIT",
+ "dependencies": {
+ "fault": "^1.0.0",
+ "highlight.js": "~10.7.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "1.31.0",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "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",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.3",
+ "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-gfm": {
+ "version": "3.1.0",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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-mdx-expression": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@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-mdx-jsx": {
+ "version": "3.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@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-phrasing": {
+ "version": "4.1.0",
+ "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",
+ "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",
+ "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-string": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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.2",
+ "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",
+ "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",
+ "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-factory-destination": {
+ "version": "2.0.1",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "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",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "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",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.53",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "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",
+ "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",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "license": "MIT"
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "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.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "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",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prismjs": {
+ "version": "1.30.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "7.2.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "19.2.8",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/react-markdown": {
+ "version": "10.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
+ "node_modules/react-redux": {
+ "version": "9.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "7.18.2",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.2",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/react-syntax-highlighter": {
+ "version": "15.6.6",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "highlight.js": "^10.4.1",
+ "highlightjs-vue": "^1.0.0",
+ "lowlight": "^1.17.0",
+ "prismjs": "^1.30.0",
+ "refractor": "^3.6.0"
+ },
+ "peerDependencies": {
+ "react": ">= 0.14.0"
+ }
+ },
+ "node_modules/recharts": {
+ "version": "3.10.1",
+ "license": "MIT",
+ "workspaces": [
+ "www"
+ ],
+ "dependencies": {
+ "@reduxjs/toolkit": "^1.9.0 || 2.x.x",
+ "clsx": "^2.1.1",
+ "decimal.js-light": "^2.5.1",
+ "es-toolkit": "^1.39.3",
+ "eventemitter3": "^5.0.1",
+ "immer": "^11.1.8",
+ "react-redux": "8.x.x || 9.x.x",
+ "reselect": "5.2.0",
+ "tiny-invariant": "^1.3.3",
+ "use-sync-external-store": "^1.2.2",
+ "victory-vendor": "^37.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
+ "node_modules/refractor": {
+ "version": "3.6.0",
+ "license": "MIT",
+ "dependencies": {
+ "hastscript": "^6.0.0",
+ "parse-entities": "^2.0.0",
+ "prismjs": "~1.27.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/character-entities": {
+ "version": "1.2.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/character-entities-legacy": {
+ "version": "1.1.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/character-reference-invalid": {
+ "version": "1.1.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/is-alphabetical": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/is-alphanumerical": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^1.0.0",
+ "is-decimal": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/is-decimal": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/is-hexadecimal": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/parse-entities": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^1.0.0",
+ "character-entities-legacy": "^1.0.0",
+ "character-reference-invalid": "^1.0.0",
+ "is-alphanumerical": "^1.0.0",
+ "is-decimal": "^1.0.0",
+ "is-hexadecimal": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/prismjs": {
+ "version": "1.27.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/rehype-katex": {
+ "version": "7.0.1",
+ "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/remark-gfm": {
+ "version": "4.0.1",
+ "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-parse": {
+ "version": "11.0.0",
+ "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",
+ "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",
+ "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-directory": {
+ "version": "2.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "license": "ISC"
+ },
+ "node_modules/reselect": {
+ "version": "5.2.0",
+ "license": "MIT"
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.62.4",
+ "@rollup/rollup-android-arm64": "4.62.4",
+ "@rollup/rollup-darwin-arm64": "4.62.4",
+ "@rollup/rollup-darwin-x64": "4.62.4",
+ "@rollup/rollup-freebsd-arm64": "4.62.4",
+ "@rollup/rollup-freebsd-x64": "4.62.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.4",
+ "@rollup/rollup-linux-arm64-musl": "4.62.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.4",
+ "@rollup/rollup-linux-loong64-musl": "4.62.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-musl": "4.62.4",
+ "@rollup/rollup-openbsd-x64": "4.62.4",
+ "@rollup/rollup-openharmony-arm64": "4.62.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.4",
+ "@rollup/rollup-win32-x64-gnu": "4.62.4",
+ "@rollup/rollup-win32-x64-msvc": "4.62.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "license": "ISC"
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "license": "MIT"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "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/stringify-entities": {
+ "version": "4.0.4",
+ "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": "6.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.10"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.10",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.8.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.66.0",
+ "@typescript-eslint/parser": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.0",
+ "@typescript-eslint/utils": "8.66.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.1.0"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.29.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "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/unist-util-find-after": {
+ "version": "5.0.0",
+ "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-is": {
+ "version": "6.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-remove-position": {
+ "version": "5.0.0",
+ "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-stringify-position": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "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.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "11.1.1",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/esm/bin/uuid"
+ }
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "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",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "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/victory-vendor": {
+ "version": "37.3.6",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "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/vitest": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/web-namespaces": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "license": "ISC"
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "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",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "license": "ISC"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/yargs/node_modules/find-up": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/locate-path": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/p-limit": {
+ "version": "2.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/yargs/node_modules/p-locate": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "4.5.7",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d4b144c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "kamidso-frontend",
+ "private": true,
+ "allowScripts": {
+ "esbuild": true
+ },
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "test": "vitest run",
+ "test:e2e": "vitest run src/e2e",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@xyflow/react": "^12.8.2",
+ "katex": "^0.16.21",
+ "lucide-react": "^1.31.0",
+ "qrcode": "^1.5.4",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "react-markdown": "^10.1.0",
+ "react-router-dom": "^7.18.2",
+ "react-syntax-highlighter": "^15.6.1",
+ "recharts": "^3.10.1",
+ "rehype-katex": "^7.0.1",
+ "remark-gfm": "^4.0.1",
+ "uuid": "^11.1.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.25.0",
+ "@tailwindcss/vite": "^4.3.3",
+ "@testing-library/dom": "^10.4.2",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.4",
+ "@types/qrcode": "^1.5.6",
+ "@types/react": "^19.1.2",
+ "@types/react-dom": "^19.1.2",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "@types/uuid": "^10.0.0",
+ "@vitejs/plugin-react": "^4.4.1",
+ "eslint": "^9.25.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.19",
+ "globals": "^16.0.0",
+ "jsdom": "^29.1.1",
+ "tailwindcss": "^4.3.3",
+ "typescript": "~5.8.3",
+ "typescript-eslint": "^8.30.1",
+ "vite": "^6.3.5",
+ "vitest": "^4.1.10"
+ }
+}
diff --git a/public/vite.svg b/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/scripts/start-frontend.ps1 b/scripts/start-frontend.ps1
new file mode 100644
index 0000000..51998b1
--- /dev/null
+++ b/scripts/start-frontend.ps1
@@ -0,0 +1,9 @@
+$ErrorActionPreference = 'Stop'
+Set-Location (Split-Path -Parent $PSScriptRoot)
+
+if (-not (Test-Path 'node_modules')) {
+ npm install
+}
+
+Write-Host "Frontend dev server: http://localhost:5173 (API proxy -> backend)"
+npm run dev
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..2f5d208
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,5 @@
+import { AppRouter } from './app/router'
+
+export default function App() {
+ return
+}
diff --git a/src/api/client.ts b/src/api/client.ts
new file mode 100644
index 0000000..db4287d
--- /dev/null
+++ b/src/api/client.ts
@@ -0,0 +1,10 @@
+export {
+ KAMIDSO_API,
+ kamidsoUrl,
+ ApiError,
+ apiFetch,
+ apiFetchEnvelope,
+ getUserId,
+ isDebugMode,
+ type KamidsoEnvelope,
+} from '../services/api'
diff --git a/src/app/nav.test.ts b/src/app/nav.test.ts
new file mode 100644
index 0000000..0d8bc57
--- /dev/null
+++ b/src/app/nav.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+import { isNavActive, pageTitleFromPath, PRIMARY_NAV } from './nav'
+
+describe('app navigation', () => {
+ it('names primary surfaces including the skills catalog', () => {
+ expect(pageTitleFromPath('/assistant')).toBe('دستیار هوشمند')
+ expect(pageTitleFromPath('/databases')).toBe('پایگاه داده')
+ expect(pageTitleFromPath('/knowledge')).toBe('دانش')
+ expect(pageTitleFromPath('/skills')).toBe('مهارتها')
+ expect(pageTitleFromPath('/settings')).toBe('تنظیمات')
+ })
+
+ it('marks skills as a primary sidebar item', () => {
+ expect(PRIMARY_NAV.some((item) => item.id === 'skills' && item.to === '/skills')).toBe(true)
+ expect(isNavActive('/skills', { to: '/skills', match: ['/skills'] })).toBe(true)
+ })
+})
diff --git a/src/app/nav.ts b/src/app/nav.ts
new file mode 100644
index 0000000..c5931f9
--- /dev/null
+++ b/src/app/nav.ts
@@ -0,0 +1,43 @@
+export const SIDEBAR_EXPANDED = 260
+export const SIDEBAR_COLLAPSED = 52
+export const SIDEBAR_COLLAPSED_STORAGE_KEY = 'kamidso.sidebarCollapsed'
+
+export type NavId = 'assistant' | 'data-map' | 'databases' | 'knowledge' | 'skills' | 'settings'
+
+export type NavItem = {
+ id: NavId
+ to: string
+ label: string
+ match: string[]
+ end?: boolean
+}
+
+export const PRIMARY_NAV: NavItem[] = [
+ { id: 'assistant', to: '/assistant', label: 'دستیار هوشمند', match: ['/assistant'] },
+ { id: 'data-map', to: '/data-map', label: 'نقشه داده', match: ['/data-map'] },
+ { id: 'databases', to: '/databases', label: 'پایگاه داده', match: ['/databases'] },
+ { id: 'knowledge', to: '/knowledge', label: 'دانش', match: ['/knowledge'] },
+ { id: 'skills', to: '/skills', label: 'مهارتها', match: ['/skills'] },
+]
+
+export const SETTINGS_NAV: NavItem = {
+ id: 'settings',
+ to: '/settings',
+ label: 'تنظیمات',
+ match: ['/settings'],
+}
+
+export function pageTitleFromPath(pathname: string): string {
+ if (pathname === '/home' || pathname.startsWith('/assistant')) return 'دستیار هوشمند'
+ if (pathname.startsWith('/data-map')) return 'نقشه داده'
+ if (pathname.startsWith('/databases')) return 'پایگاه داده'
+ if (pathname.startsWith('/knowledge')) return 'دانش'
+ if (pathname.startsWith('/skills')) return 'مهارتها'
+ if (pathname.startsWith('/settings')) return 'تنظیمات'
+ return 'Kamidso'
+}
+
+export function isNavActive(pathname: string, item: Pick) {
+ if (item.end) return pathname === item.to
+ return item.match.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))
+}
diff --git a/src/app/providers/FeatureFlagProvider.tsx b/src/app/providers/FeatureFlagProvider.tsx
new file mode 100644
index 0000000..6f4021b
--- /dev/null
+++ b/src/app/providers/FeatureFlagProvider.tsx
@@ -0,0 +1,103 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react'
+import { apiFetch } from '../../api/client'
+import { useAuth } from '../../features/auth/AuthContext'
+
+export type FeatureFlags = {
+ chat: boolean
+ files: boolean
+ skills: boolean
+ knowledge: boolean
+ datasources: boolean
+ apps: boolean
+ awel: boolean
+ reports: boolean
+ dashboards: boolean
+ widgets: boolean
+ radar: boolean
+ new_agent_workspace: boolean
+}
+
+const DEFAULT_FLAGS: FeatureFlags = {
+ chat: true,
+ files: true,
+ skills: true,
+ knowledge: true,
+ datasources: true,
+ apps: false,
+ awel: false,
+ reports: false,
+ dashboards: false,
+ widgets: false,
+ radar: false,
+ new_agent_workspace: true,
+}
+
+type FeatureFlagContextValue = {
+ flags: FeatureFlags
+ developerMode: boolean
+ loading: boolean
+ refresh: () => Promise
+ isEnabled: (key: keyof FeatureFlags) => boolean
+}
+
+const FeatureFlagContext = createContext(null)
+
+export function FeatureFlagProvider({ children }: { children: ReactNode }) {
+ const { status } = useAuth()
+ const [flags, setFlags] = useState(DEFAULT_FLAGS)
+ const [developerMode, setDeveloperMode] = useState(false)
+ const [loading, setLoading] = useState(true)
+
+ const refresh = useCallback(async () => {
+ if (status !== 'authenticated') {
+ setFlags(DEFAULT_FLAGS)
+ setDeveloperMode(false)
+ setLoading(false)
+ return
+ }
+ try {
+ const data = await apiFetch<{
+ features?: Partial
+ developer_mode?: boolean
+ }>('/features')
+ setFlags({ ...DEFAULT_FLAGS, ...(data.features ?? {}) })
+ setDeveloperMode(Boolean(data.developer_mode))
+ } catch {
+ setFlags(DEFAULT_FLAGS)
+ setDeveloperMode(false)
+ } finally {
+ setLoading(false)
+ }
+ }, [status])
+
+ useEffect(() => {
+ void refresh()
+ }, [refresh])
+
+ const value = useMemo(
+ () => ({
+ flags,
+ developerMode,
+ loading,
+ refresh,
+ isEnabled: (key: keyof FeatureFlags) => Boolean(flags[key]),
+ }),
+ [flags, developerMode, loading, refresh],
+ )
+
+ return {children}
+}
+
+export function useFeatureFlags() {
+ const ctx = useContext(FeatureFlagContext)
+ if (!ctx) throw new Error('useFeatureFlags must be used within FeatureFlagProvider')
+ return ctx
+}
diff --git a/src/app/router.tsx b/src/app/router.tsx
new file mode 100644
index 0000000..975db78
--- /dev/null
+++ b/src/app/router.tsx
@@ -0,0 +1,61 @@
+import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
+import { AppShell } from '../components/layout/AppShell'
+import { AssistantPage } from '../pages/AssistantPage'
+import { DatabasesPage } from '../pages/DatabasesPage'
+import { DataMapPage } from '../features/data-map/DataMapPage'
+import { KnowledgePage } from '../pages/KnowledgePage'
+import { KnowledgeDetailPage } from '../pages/KnowledgeDetailPage'
+import { SkillsPage } from '../pages/SkillsPage'
+import { SettingsPage } from '../pages/SettingsPage'
+import { OrganizationProfilePage } from '../features/settings/OrganizationProfilePage'
+import { SkillSettingsPage } from '../features/settings/SkillSettingsPage'
+import { LandingPage } from '../features/landing/pages/LandingPage'
+import { ShareReportPage } from '../pages/ShareReportPage'
+import { AuthProvider } from '../features/auth/AuthContext'
+import { RequireAuth } from '../features/auth/RequireAuth'
+import { LoginPage } from '../features/auth/pages/LoginPage'
+import { WorkspaceProvider } from './workspace/WorkspaceContext'
+import { FeatureFlagProvider } from './providers/FeatureFlagProvider'
+
+export function AppRouter() {
+ return (
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+ }
+ />
+
+
+
+ )
+}
diff --git a/src/app/workspace/AssistantRuntimeContext.tsx b/src/app/workspace/AssistantRuntimeContext.tsx
new file mode 100644
index 0000000..11821d7
--- /dev/null
+++ b/src/app/workspace/AssistantRuntimeContext.tsx
@@ -0,0 +1,21 @@
+import { createContext, useContext, type PropsWithChildren } from 'react'
+import { useRunStream } from '../../features/assistant/stream/useRunStream'
+
+type AssistantRuntime = ReturnType
+
+const AssistantRuntimeContext = createContext(null)
+
+export function AssistantRuntimeProvider({ children }: PropsWithChildren) {
+ const stream = useRunStream()
+ return (
+ {children}
+ )
+}
+
+export function useAssistantRuntime() {
+ const ctx = useContext(AssistantRuntimeContext)
+ if (!ctx) {
+ throw new Error('useAssistantRuntime must be used within AssistantRuntimeProvider')
+ }
+ return ctx
+}
diff --git a/src/app/workspace/ConversationsContext.test.tsx b/src/app/workspace/ConversationsContext.test.tsx
new file mode 100644
index 0000000..246fe44
--- /dev/null
+++ b/src/app/workspace/ConversationsContext.test.tsx
@@ -0,0 +1,67 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { ConversationsProvider, useConversations } from './ConversationsContext'
+import { listConversations } from '../../features/assistant/api/conversations.api'
+
+vi.mock('../../features/assistant/api/conversations.api', () => ({
+ listConversations: vi.fn(),
+}))
+
+const listMock = vi.mocked(listConversations)
+
+function Probe() {
+ const { items, hasMore, nextPage } = useConversations()
+ return (
+
+
{items.length}
+
{String(hasMore)}
+
+ {items.map((item) => (
+ - {item.title}
+ ))}
+
+
+
+ )
+}
+
+describe('ConversationsProvider', () => {
+ beforeEach(() => {
+ listMock.mockReset()
+ listMock.mockImplementation(async ({ offset = 0, limit = 20 } = {}) => {
+ const all = Array.from({ length: 45 }, (_, index) => ({
+ id: String(index + 1),
+ conversation_id: String(index + 1),
+ title: `گفتگو ${index + 1}`,
+ }))
+ return {
+ items: all.slice(offset, offset + limit),
+ total: all.length,
+ limit,
+ offset,
+ }
+ })
+ })
+
+ it('appends the next page instead of replacing the list', async () => {
+ const user = userEvent.setup()
+ render(
+
+
+ ,
+ )
+
+ await waitFor(() => expect(screen.getByTestId('count')).toHaveTextContent('20'))
+ expect(screen.getByTestId('more')).toHaveTextContent('true')
+ expect(screen.getByText('گفتگو 1')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'next' }))
+ await waitFor(() => expect(screen.getByTestId('count')).toHaveTextContent('40'))
+ expect(screen.getByText('گفتگو 1')).toBeInTheDocument()
+ expect(screen.getByText('گفتگو 21')).toBeInTheDocument()
+ expect(listMock).toHaveBeenLastCalledWith({ offset: 20, limit: 20 })
+ })
+})
diff --git a/src/app/workspace/ConversationsContext.tsx b/src/app/workspace/ConversationsContext.tsx
new file mode 100644
index 0000000..02b544e
--- /dev/null
+++ b/src/app/workspace/ConversationsContext.tsx
@@ -0,0 +1,170 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type PropsWithChildren,
+} from 'react'
+import {
+ listConversations,
+ type ConversationListItem,
+} from '../../features/assistant/api/conversations.api'
+
+export const CONVERSATION_PAGE_SIZE = 20
+
+type ConversationsContextValue = {
+ items: ConversationListItem[]
+ total: number
+ hasMore: boolean
+ hasPrev: boolean
+ loading: boolean
+ pageSize: number
+ setPageSize: (size: number) => void
+ nextPage: () => Promise
+ prevPage: () => Promise
+ refresh: () => Promise
+}
+
+const ConversationsContext = createContext(null)
+
+function pageSlice(source: ConversationListItem[], page: number, size: number) {
+ const start = page * size
+ return source.slice(start, start + size).filter(Boolean)
+}
+
+function loadedItems(source: ConversationListItem[]) {
+ return source.filter(Boolean)
+}
+
+export function ConversationsProvider({ children }: PropsWithChildren) {
+ const cache = useRef([])
+ const [items, setItems] = useState([])
+ const [total, setTotal] = useState(0)
+ const [page, setPage] = useState(0)
+ const [pageSize, setPageSizeState] = useState(CONVERSATION_PAGE_SIZE)
+ const [loading, setLoading] = useState(false)
+ const pageRef = useRef(0)
+ const pageSizeRef = useRef(CONVERSATION_PAGE_SIZE)
+ const totalRef = useRef(0)
+ const loadingRef = useRef(false)
+
+ const publish = useCallback((nextPage: number) => {
+ setItems(loadedItems(cache.current))
+ setPage(nextPage)
+ pageRef.current = nextPage
+ }, [])
+
+ const loadPage = useCallback(async (nextPage: number, size: number, force = false) => {
+ if (size <= 0 || loadingRef.current) return
+ const start = nextPage * size
+ const cached = pageSlice(cache.current, nextPage, size)
+ const knownTotal = totalRef.current
+ const complete =
+ cached.length === size || (knownTotal > 0 && start + cached.length >= knownTotal)
+
+ if (!force && complete) {
+ publish(nextPage)
+ return
+ }
+
+ loadingRef.current = true
+ setLoading(true)
+ try {
+ const result = await listConversations({ offset: start, limit: size })
+ totalRef.current = result.total
+ setTotal(result.total)
+ if (start === 0) {
+ cache.current = result.items
+ } else {
+ const next = cache.current.slice()
+ result.items.forEach((item, index) => {
+ next[start + index] = item
+ })
+ cache.current = next
+ }
+ publish(nextPage)
+ } catch {
+ if (force) {
+ cache.current = []
+ totalRef.current = 0
+ setItems([])
+ setTotal(0)
+ pageRef.current = 0
+ setPage(0)
+ }
+ } finally {
+ loadingRef.current = false
+ setLoading(false)
+ }
+ }, [publish])
+
+ useEffect(() => {
+ void loadPage(0, pageSizeRef.current, true)
+ }, [loadPage])
+
+ const setPageSize = useCallback(
+ (size: number) => {
+ const next = Math.max(1, Math.floor(size))
+ if (next === pageSizeRef.current) return
+ pageSizeRef.current = next
+ setPageSizeState(next)
+ pageRef.current = 0
+ void loadPage(0, next, true)
+ },
+ [loadPage],
+ )
+
+ const nextPage = useCallback(async () => {
+ const size = pageSizeRef.current
+ if (size <= 0 || loadingRef.current) return false
+ const loaded = loadedItems(cache.current).length
+ if (totalRef.current > 0 && loaded >= totalRef.current) return false
+ const next = pageRef.current + 1
+ const before = loaded
+ await loadPage(next, size)
+ return loadedItems(cache.current).length > before
+ }, [loadPage])
+
+ const prevPage = useCallback(async () => {
+ const size = pageSizeRef.current
+ if (size <= 0 || pageRef.current <= 0) return
+ await loadPage(pageRef.current - 1, size)
+ }, [loadPage])
+
+ const refresh = useCallback(async () => {
+ cache.current = []
+ pageRef.current = 0
+ const size = pageSizeRef.current
+ if (size > 0) await loadPage(0, size, true)
+ }, [loadPage])
+
+ const hasMore = pageSize > 0 && items.length < total
+ const hasPrev = page > 0
+
+ const value = useMemo(
+ () => ({
+ items,
+ total,
+ hasMore,
+ hasPrev,
+ loading,
+ pageSize,
+ setPageSize,
+ nextPage,
+ prevPage,
+ refresh,
+ }),
+ [items, total, hasMore, hasPrev, loading, pageSize, setPageSize, nextPage, prevPage, refresh],
+ )
+
+ return {children}
+}
+
+export function useConversations() {
+ const ctx = useContext(ConversationsContext)
+ if (!ctx) throw new Error('useConversations must be used within ConversationsProvider')
+ return ctx
+}
diff --git a/src/app/workspace/WorkspaceContext.tsx b/src/app/workspace/WorkspaceContext.tsx
new file mode 100644
index 0000000..bcfbd36
--- /dev/null
+++ b/src/app/workspace/WorkspaceContext.tsx
@@ -0,0 +1,37 @@
+import { createContext, useContext, type PropsWithChildren } from 'react'
+import { useAuth } from '../../features/auth/AuthContext'
+
+export type WorkspaceContextValue = {
+ organization: { id: string; name: string }
+ activeWorkspace: { id: string; name: string; organization_id?: string } | null
+ loading: boolean
+}
+
+const WorkspaceContext = createContext(null)
+
+export function WorkspaceProvider({ children }: PropsWithChildren) {
+ const { user, activeWorkspace } = useAuth()
+ const value: WorkspaceContextValue = {
+ organization: {
+ id: activeWorkspace?.organization_id ?? user?.workspace_id ?? user?.user_id ?? '',
+ name: activeWorkspace?.name ?? user?.name ?? 'کامیدسو',
+ },
+ activeWorkspace: activeWorkspace
+ ? {
+ id: activeWorkspace.id,
+ name: activeWorkspace.name,
+ organization_id: activeWorkspace.organization_id,
+ }
+ : user?.workspace_id
+ ? { id: user.workspace_id, name: user.name ?? 'فضای کاری' }
+ : null,
+ loading: false,
+ }
+ return {children}
+}
+
+export function useWorkspace() {
+ const ctx = useContext(WorkspaceContext)
+ if (!ctx) throw new Error('useWorkspace must be used within WorkspaceProvider')
+ return ctx
+}
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
new file mode 100644
index 0000000..4d4b089
--- /dev/null
+++ b/src/components/Button.tsx
@@ -0,0 +1,45 @@
+import type { ButtonHTMLAttributes, PropsWithChildren } from 'react'
+
+type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
+type Size = 'sm' | 'md' | 'lg'
+
+const styles: Record = {
+ primary:
+ 'bg-accent text-white shadow-[0_8px_18px_-12px_rgba(37,99,235,0.8)] hover:bg-accent-hover disabled:bg-accent/40 disabled:shadow-none',
+ secondary:
+ 'bg-white text-ink border border-line hover:border-line-strong hover:bg-surface-subtle disabled:opacity-50',
+ ghost: 'bg-transparent text-ink-muted hover:bg-surface-subtle hover:text-ink disabled:opacity-50',
+ danger: 'bg-danger text-white hover:bg-red-600 disabled:opacity-50',
+}
+
+const sizes: Record = {
+ sm: 'h-8 gap-1.5 px-3 text-xs',
+ md: 'h-9 gap-2 px-4 text-sm',
+ lg: 'h-11 gap-2 px-5 text-sm',
+}
+
+type Props = PropsWithChildren<
+ ButtonHTMLAttributes & {
+ variant?: Variant
+ size?: Size
+ }
+>
+
+export function Button({
+ children,
+ className = '',
+ variant = 'primary',
+ size = 'md',
+ type = 'button',
+ ...rest
+}: Props) {
+ return (
+
+ )
+}
diff --git a/src/components/Card.tsx b/src/components/Card.tsx
new file mode 100644
index 0000000..1aef2b4
--- /dev/null
+++ b/src/components/Card.tsx
@@ -0,0 +1,23 @@
+import type { PropsWithChildren, ReactNode } from 'react'
+
+type Props = PropsWithChildren<{
+ className?: string
+ title?: string
+ action?: ReactNode
+}>
+
+export function Card({ children, className = '', title, action }: Props) {
+ return (
+
+ {(title || action) && (
+
+ {title ?
{title}
: }
+ {action}
+
+ )}
+ {children}
+
+ )
+}
diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx
new file mode 100644
index 0000000..ac7c06b
--- /dev/null
+++ b/src/components/ErrorMessage.tsx
@@ -0,0 +1,14 @@
+import { AlertCircle } from 'lucide-react'
+
+export function ErrorMessage({ message }: { message: string }) {
+ if (!message) return null
+ return (
+
+ )
+}
diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx
new file mode 100644
index 0000000..9e38453
--- /dev/null
+++ b/src/components/Loading.tsx
@@ -0,0 +1,10 @@
+import { LoaderCircle } from 'lucide-react'
+
+export function Loading({ label = 'در حال بارگذاری...' }: { label?: string }) {
+ return (
+
+
+ {label}
+
+ )
+}
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
new file mode 100644
index 0000000..532a1a8
--- /dev/null
+++ b/src/components/Modal.tsx
@@ -0,0 +1,77 @@
+import { useEffect, type PropsWithChildren, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import { X } from 'lucide-react'
+import { useBodyScrollLock } from './ui/useFocusTrap'
+
+type Size = 'sm' | 'md' | 'lg' | 'xl'
+
+const WIDTHS: Record = {
+ sm: 'max-w-md',
+ md: 'max-w-lg',
+ lg: 'max-w-2xl',
+ xl: 'max-w-4xl',
+}
+
+type Props = PropsWithChildren<{
+ open: boolean
+ title: string
+ description?: string
+ size?: Size
+ footer?: ReactNode
+ onClose: () => void
+}>
+
+export function Modal({ open, title, description, size = 'md', footer, onClose, children }: Props) {
+ useBodyScrollLock(open)
+ useEffect(() => {
+ if (!open) return
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') onClose()
+ }
+ document.addEventListener('keydown', onKeyDown)
+ return () => document.removeEventListener('keydown', onKeyDown)
+ }, [open, onClose])
+
+ if (!open) return null
+
+ return createPortal(
+
+
+
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+
+
+
{children}
+ {footer ? (
+
+ {footer}
+
+ ) : null}
+
+
,
+ document.body,
+ )
+}
diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx
new file mode 100644
index 0000000..5a33043
--- /dev/null
+++ b/src/components/layout/AppShell.tsx
@@ -0,0 +1,97 @@
+import { useCallback, useEffect, useState, type PropsWithChildren } from 'react'
+import { useLocation } from 'react-router-dom'
+import { Menu } from 'lucide-react'
+import { SIDEBAR_COLLAPSED_STORAGE_KEY, pageTitleFromPath } from '../../app/nav'
+import { ConversationsProvider } from '../../app/workspace/ConversationsContext'
+import { AssistantRuntimeProvider } from '../../app/workspace/AssistantRuntimeContext'
+import { AppSidebar } from './AppSidebar'
+
+function storedPreference() {
+ try {
+ return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === '1'
+ } catch {
+ return false
+ }
+}
+
+export function AppShell({ children }: PropsWithChildren) {
+ const { pathname, search } = useLocation()
+ const fullscreen =
+ pathname.startsWith('/dashboards/') &&
+ new URLSearchParams(search).get('fullscreen') === '1'
+ const lockPageScroll = pathname.startsWith('/assistant')
+ const [collapsed, setCollapsed] = useState(storedPreference)
+ const [mobileOpen, setMobileOpen] = useState(false)
+
+ useEffect(() => {
+ const onResize = () => {
+ if (window.innerWidth < 1024) setCollapsed(true)
+ else setCollapsed(storedPreference())
+ if (window.innerWidth >= 768) setMobileOpen(false)
+ }
+ onResize()
+ window.addEventListener('resize', onResize)
+ return () => window.removeEventListener('resize', onResize)
+ }, [])
+
+ const toggleCollapsed = useCallback(() => {
+ setCollapsed((value) => {
+ const next = !value
+ try {
+ localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, next ? '1' : '0')
+ } catch {
+ // preference persistence is best-effort
+ }
+ return next
+ })
+ }, [])
+
+ if (fullscreen) {
+ return {children}
+ }
+
+ return (
+
+
+
+
+
+ {mobileOpen ? (
+
+
+ ) : null}
+
+
+
+
+ {pageTitleFromPath(pathname)}
+
+
+ {children}
+
+
+
+
+
+ )
+}
diff --git a/src/components/layout/AppSidebar.test.tsx b/src/components/layout/AppSidebar.test.tsx
new file mode 100644
index 0000000..baf8372
--- /dev/null
+++ b/src/components/layout/AppSidebar.test.tsx
@@ -0,0 +1,75 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+import { AppSidebar } from './AppSidebar'
+
+const logout = vi.fn(async () => {})
+
+vi.mock('../../features/auth/AuthContext', () => ({
+ useAuth: () => ({
+ user: { user_id: '1', email: 'admin@kamidso.local' },
+ logout,
+ }),
+}))
+
+const nextPage = vi.fn(async () => false)
+
+vi.mock('../../app/workspace/ConversationsContext', () => ({
+ useConversations: () => ({
+ items: [
+ { id: 'c1', conversation_id: 'c1', title: 'می خوام بهترین فروشند ام رو بهم بگی کی هست ؟' },
+ ],
+ hasMore: true,
+ hasPrev: false,
+ loading: false,
+ setPageSize: () => {},
+ nextPage,
+ prevPage: async () => {},
+ }),
+}))
+
+describe('AppSidebar account footer', () => {
+ it('opens the account page from the profile and keeps logout behind a confirm', async () => {
+ const user = userEvent.setup()
+ logout.mockClear()
+ render(
+
+
+ ,
+ )
+
+ const profile = screen.getByRole('link', { name: 'حساب کاربری' })
+ expect(profile).toHaveAttribute('href', '/settings')
+ expect(screen.getByText('admin@kamidso.local')).toBeInTheDocument()
+
+ await user.click(profile)
+ expect(logout).not.toHaveBeenCalled()
+ expect(screen.queryByText('مطمئن هستید که میخواهید از حساب کاربری خارج شوید؟')).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'خروج' }))
+ expect(screen.getByText('مطمئن هستید که میخواهید از حساب کاربری خارج شوید؟')).toBeInTheDocument()
+ expect(logout).not.toHaveBeenCalled()
+ })
+
+ it('scrolls conversations and loads the next page at the end', async () => {
+ nextPage.mockClear()
+ render(
+
+
+ ,
+ )
+
+ expect(screen.queryByRole('button', { name: 'مشاهده بیشتر' })).not.toBeInTheDocument()
+ const list = screen.getByTestId('conversation-list')
+ expect(list).toHaveClass('sidebar-scroll')
+ expect(screen.getByText('می خوام بهترین فروشند ام رو بهم بگی کی هست ؟')).toBeInTheDocument()
+
+ Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 800 })
+ Object.defineProperty(list, 'clientHeight', { configurable: true, value: 170 })
+ Object.defineProperty(list, 'scrollTop', { configurable: true, value: 740 })
+ fireEvent.scroll(list)
+
+ expect(nextPage).toHaveBeenCalled()
+ })
+})
diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx
new file mode 100644
index 0000000..d4cb6a3
--- /dev/null
+++ b/src/components/layout/AppSidebar.tsx
@@ -0,0 +1,296 @@
+import { NavLink, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
+import { useCallback, useEffect, useRef } from 'react'
+import {
+ BookOpen,
+ Database,
+ Network,
+ Loader2,
+ LogOut,
+ MessageSquarePlus,
+ PanelRightClose,
+ Settings,
+ Sparkles,
+ WandSparkles,
+} from 'lucide-react'
+import { isNavActive, PRIMARY_NAV, SETTINGS_NAV, SIDEBAR_COLLAPSED, SIDEBAR_EXPANDED } from '../../app/nav'
+import { useConversations } from '../../app/workspace/ConversationsContext'
+import { useAuth } from '../../features/auth/AuthContext'
+import { useLogoutConfirm } from '../../features/auth/useLogoutConfirm'
+
+const ICONS = {
+ assistant: Sparkles,
+ 'data-map': Network,
+ databases: Database,
+ knowledge: BookOpen,
+ skills: WandSparkles,
+} as const
+
+function navClass(active: boolean, compact: boolean) {
+ return `group relative flex items-center gap-2.5 rounded-xl text-sm transition ${
+ compact ? 'mx-auto h-10 w-10 justify-center' : 'h-10 px-3'
+ } ${
+ active
+ ? 'bg-accent-soft font-medium text-accent'
+ : 'text-ink-muted hover:bg-surface-subtle hover:text-ink'
+ }`
+}
+
+export function AppSidebar({
+ collapsed,
+ onToggleCollapsed,
+ overlay = false,
+ onNavigate,
+}: {
+ collapsed: boolean
+ onToggleCollapsed?: () => void
+ overlay?: boolean
+ onNavigate?: () => void
+}) {
+ const { pathname } = useLocation()
+ const [searchParams] = useSearchParams()
+ const navigate = useNavigate()
+ const { user } = useAuth()
+ const { ask: askLogout, dialog: logoutDialog } = useLogoutConfirm()
+ const { items, hasMore, loading, nextPage } = useConversations()
+ const activeConvId = searchParams.get('id')
+ const width = overlay || !collapsed ? SIDEBAR_EXPANDED : SIDEBAR_COLLAPSED
+ const compact = collapsed && !overlay
+ const listRef = useRef(null)
+ const loadingMoreRef = useRef(false)
+ const filledForLengthRef = useRef(-1)
+ const hasMoreRef = useRef(hasMore)
+ const loadingRef = useRef(loading)
+ hasMoreRef.current = hasMore
+ loadingRef.current = loading
+
+ const loadMoreIfNeeded = useCallback(
+ async (el: HTMLElement) => {
+ if (!hasMoreRef.current || loadingRef.current || loadingMoreRef.current) return
+ const remaining = el.scrollHeight - el.scrollTop - el.clientHeight
+ const noOverflow = el.scrollHeight <= el.clientHeight + 4
+ if (!noOverflow && remaining > 64) return
+ loadingMoreRef.current = true
+ try {
+ await nextPage()
+ } finally {
+ loadingMoreRef.current = false
+ }
+ },
+ [nextPage],
+ )
+
+ useEffect(() => {
+ const el = listRef.current
+ if (compact || !el || loading || !hasMore) return
+ if (filledForLengthRef.current === items.length) return
+ if (el.scrollHeight <= el.clientHeight + 4) {
+ filledForLengthRef.current = items.length
+ void loadMoreIfNeeded(el)
+ }
+ }, [compact, items.length, hasMore, loading, loadMoreIfNeeded])
+
+ function goNewChat() {
+ onNavigate?.()
+ navigate('/assistant', { state: { newChat: Date.now() } })
+ }
+
+ function goConversation(id: string) {
+ onNavigate?.()
+ navigate(`/assistant?id=${encodeURIComponent(id)}`)
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx
new file mode 100644
index 0000000..414116d
--- /dev/null
+++ b/src/components/layout/MobileNav.tsx
@@ -0,0 +1,48 @@
+import { BookOpen, Database, House, Network, Settings, Sparkles, WandSparkles } from 'lucide-react'
+import { NavLink, useLocation } from 'react-router-dom'
+import { isNavActive, PRIMARY_NAV } from '../../app/nav'
+
+const ICONS = {
+ home: House,
+ assistant: Sparkles,
+ 'data-map': Network,
+ databases: Database,
+ knowledge: BookOpen,
+ skills: WandSparkles,
+ settings: Settings,
+} as const
+
+const MOBILE_IDS = ['assistant', 'data-map', 'databases', 'knowledge', 'skills'] as const
+
+export function MobileNav() {
+ const { pathname } = useLocation()
+ const items = PRIMARY_NAV.filter((item) => MOBILE_IDS.includes(item.id as (typeof MOBILE_IDS)[number]))
+
+ return (
+
+ )
+}
diff --git a/src/components/layout/PrimarySidebar.tsx b/src/components/layout/PrimarySidebar.tsx
new file mode 100644
index 0000000..dff5db0
--- /dev/null
+++ b/src/components/layout/PrimarySidebar.tsx
@@ -0,0 +1,118 @@
+import { NavLink, useLocation } from 'react-router-dom'
+import {
+ BookOpen,
+ ChevronRight,
+ Database,
+ Network,
+ House,
+ PanelRightClose,
+ Settings,
+ Sparkles,
+ WandSparkles,
+} from 'lucide-react'
+import { isNavActive, PRIMARY_NAV, SIDEBAR_COLLAPSED, SIDEBAR_EXPANDED } from '../../app/nav'
+
+const ICONS = {
+ home: House,
+ assistant: Sparkles,
+ 'data-map': Network,
+ databases: Database,
+ knowledge: BookOpen,
+ skills: WandSparkles,
+ settings: Settings,
+} as const
+
+export function PrimarySidebar({
+ collapsed,
+ onNavigate,
+ onToggleCollapsed,
+ overlay = false,
+}: {
+ collapsed: boolean
+ onNavigate?: () => void
+ onToggleCollapsed?: () => void
+ overlay?: boolean
+}) {
+ const { pathname } = useLocation()
+ const width = collapsed ? SIDEBAR_COLLAPSED : SIDEBAR_EXPANDED
+
+ return (
+
+ )
+}
diff --git a/src/components/layout/Topbar.tsx b/src/components/layout/Topbar.tsx
new file mode 100644
index 0000000..e6f44c6
--- /dev/null
+++ b/src/components/layout/Topbar.tsx
@@ -0,0 +1,68 @@
+import { LogOut, Menu } from 'lucide-react'
+import { Link, useLocation } from 'react-router-dom'
+import { pageTitleFromPath } from '../../app/nav'
+import { useWorkspace } from '../../app/workspace/WorkspaceContext'
+import { useAuth } from '../../features/auth/AuthContext'
+import { useLogoutConfirm } from '../../features/auth/useLogoutConfirm'
+
+export function Topbar({ onMenu }: { onMenu: () => void }) {
+ const { pathname } = useLocation()
+ const { user } = useAuth()
+ const { organization } = useWorkspace()
+ const { ask, dialog } = useLogoutConfirm()
+ const title = pageTitleFromPath(pathname)
+ const hideTitle = pathname.startsWith('/assistant')
+ const orgLabel = organization.name && organization.name !== 'فضای کاری' ? organization.name : null
+
+ return (
+
+
+
+ {hideTitle ? null : (
+
{title}
+ )}
+
+
+
+ {orgLabel ? (
+
+ {orgLabel}
+
+ ) : null}
+
+ {user?.email}
+
+
+ {(user?.email || 'K').slice(0, 1).toUpperCase()}
+
+
+
+ {dialog}
+
+ )
+}
diff --git a/src/components/ui/CardMenu.tsx b/src/components/ui/CardMenu.tsx
new file mode 100644
index 0000000..c486dd1
--- /dev/null
+++ b/src/components/ui/CardMenu.tsx
@@ -0,0 +1,127 @@
+import { MoreHorizontal } from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+import { useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { selectMenuPanelClass } from './SelectMenu'
+
+export type CardMenuItem = {
+ label: string
+ icon?: LucideIcon
+ onSelect: () => void
+ tone?: 'default' | 'danger'
+ disabled?: boolean
+}
+
+type MenuPos = { top: number; left: number; openUp: boolean }
+
+export function CardMenu({ items, label = 'عملیات بیشتر' }: { items: CardMenuItem[]; label?: string }) {
+ const [open, setOpen] = useState(false)
+ const [pos, setPos] = useState(null)
+ const triggerRef = useRef(null)
+ const menuRef = useRef(null)
+
+ useLayoutEffect(() => {
+ if (!open || !triggerRef.current) return
+ const rect = triggerRef.current.getBoundingClientRect()
+ const menuWidth = 188
+ const menuHeight = Math.min(240, items.length * 40 + 16)
+ const spaceBelow = window.innerHeight - rect.bottom
+ const openUp = spaceBelow < menuHeight && rect.top > menuHeight
+ setPos({
+ top: openUp ? rect.top - 6 : rect.bottom + 6,
+ left: Math.min(Math.max(8, rect.right - menuWidth), window.innerWidth - menuWidth - 8),
+ openUp,
+ })
+ }, [open, items.length])
+
+ useEffect(() => {
+ if (!open) return
+ function onPointerDown(event: MouseEvent) {
+ const target = event.target as Node
+ if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) {
+ setOpen(false)
+ }
+ }
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') setOpen(false)
+ }
+ document.addEventListener('mousedown', onPointerDown)
+ document.addEventListener('keydown', onKeyDown)
+ return () => {
+ document.removeEventListener('mousedown', onPointerDown)
+ document.removeEventListener('keydown', onKeyDown)
+ }
+ }, [open])
+
+ useEffect(() => {
+ if (!open) return
+ function onScroll() {
+ setOpen(false)
+ }
+ window.addEventListener('scroll', onScroll, true)
+ return () => window.removeEventListener('scroll', onScroll, true)
+ }, [open])
+
+ if (items.length === 0) return null
+
+ const menu =
+ open && pos
+ ? createPortal(
+
+ {items.map((item) => {
+ const Icon = item.icon
+ return (
+
+ )
+ })}
+
,
+ document.body,
+ )
+ : null
+
+ return (
+ <>
+
+ {menu}
+ >
+ )
+}
diff --git a/src/components/ui/Collapsible.tsx b/src/components/ui/Collapsible.tsx
new file mode 100644
index 0000000..a008265
--- /dev/null
+++ b/src/components/ui/Collapsible.tsx
@@ -0,0 +1,97 @@
+import { ChevronDown } from 'lucide-react'
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useState,
+ type PropsWithChildren,
+} from 'react'
+
+type CollapsibleContextValue = {
+ open: boolean
+ toggle: () => void
+ onOpenChange?: (open: boolean) => void
+}
+
+const CollapsibleContext = createContext(null)
+
+function useCollapsible() {
+ const ctx = useContext(CollapsibleContext)
+ if (!ctx) throw new Error('Collapsible components must be used within Collapsible')
+ return ctx
+}
+
+function CollapsibleRoot({
+ children,
+ open: controlledOpen,
+ defaultOpen = false,
+ onOpenChange,
+ className = '',
+}: PropsWithChildren<{
+ open?: boolean
+ defaultOpen?: boolean
+ onOpenChange?: (open: boolean) => void
+ className?: string
+}>) {
+ const [internalOpen, setInternalOpen] = useState(defaultOpen)
+ const isControlled = controlledOpen !== undefined
+ const open = isControlled ? controlledOpen : internalOpen
+
+ const toggle = useCallback(() => {
+ const next = !open
+ if (!isControlled) setInternalOpen(next)
+ onOpenChange?.(next)
+ }, [open, isControlled, onOpenChange])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function CollapsibleTrigger({
+ children,
+ className = '',
+}: PropsWithChildren<{ className?: string }>) {
+ const { toggle, open } = useCollapsible()
+ return (
+
+ )
+}
+
+function CollapsibleContent({
+ children,
+ className = '',
+}: PropsWithChildren<{ className?: string }>) {
+ const { open } = useCollapsible()
+ if (!open) return null
+ return {children}
+}
+
+function CollapsibleArrow({ className = '' }: { className?: string }) {
+ const { open } = useCollapsible()
+ return (
+
+ )
+}
+
+export const Collapsible = Object.assign(CollapsibleRoot, {
+ Trigger: CollapsibleTrigger,
+ Content: CollapsibleContent,
+ Arrow: CollapsibleArrow,
+})
diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx
new file mode 100644
index 0000000..94b083b
--- /dev/null
+++ b/src/components/ui/ConfirmDialog.tsx
@@ -0,0 +1,54 @@
+import { AlertTriangle, Info, Loader2 } from 'lucide-react'
+import { Modal } from '../Modal'
+import { Button } from '../Button'
+
+type Props = {
+ open: boolean
+ title: string
+ message: string
+ confirmLabel?: string
+ cancelLabel?: string
+ tone?: 'danger' | 'default'
+ busy?: boolean
+ onConfirm: () => void
+ onCancel: () => void
+}
+
+export function ConfirmDialog({
+ open,
+ title,
+ message,
+ confirmLabel = 'تأیید',
+ cancelLabel = 'انصراف',
+ tone = 'default',
+ busy = false,
+ onConfirm,
+ onCancel,
+}: Props) {
+ const danger = tone === 'danger'
+ const Icon = danger ? AlertTriangle : Info
+
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/ui/Drawer.tsx b/src/components/ui/Drawer.tsx
new file mode 100644
index 0000000..bf80b5b
--- /dev/null
+++ b/src/components/ui/Drawer.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useRef, type PropsWithChildren, type ReactNode } from 'react'
+import { X } from 'lucide-react'
+import { useBodyScrollLock, useFocusTrap } from './useFocusTrap'
+
+type Props = PropsWithChildren<{
+ open: boolean
+ title: string
+ description?: string
+ /** Logical edge: `end` is the left side in RTL. Defaults to `end`. */
+ side?: 'start' | 'end'
+ width?: string
+ footer?: ReactNode
+ headerExtra?: ReactNode
+ onClose: () => void
+}>
+
+export function Drawer({
+ open,
+ title,
+ description,
+ side = 'end',
+ width = 'w-full max-w-md',
+ footer,
+ headerExtra,
+ onClose,
+ children,
+}: Props) {
+ const panelRef = useRef(null)
+ useFocusTrap(panelRef, open)
+ useBodyScrollLock(open)
+
+ useEffect(() => {
+ if (!open) return
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') onClose()
+ }
+ document.addEventListener('keydown', onKeyDown)
+ return () => document.removeEventListener('keydown', onKeyDown)
+ }, [open, onClose])
+
+ if (!open) return null
+
+ return (
+
+
+
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+
+ {headerExtra}
+
+
+
+
+
{children}
+
+ {footer ? (
+
+ {footer}
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx
new file mode 100644
index 0000000..58755ea
--- /dev/null
+++ b/src/components/ui/EmptyState.tsx
@@ -0,0 +1,36 @@
+import type { LucideIcon } from 'lucide-react'
+import type { ReactNode } from 'react'
+import { Link } from 'react-router-dom'
+import { Button } from '../Button'
+
+export function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ actionTo,
+ actionLabel,
+ children,
+}: {
+ icon: LucideIcon
+ title: string
+ description: string
+ actionTo?: string | null
+ actionLabel?: string
+ children?: ReactNode
+}) {
+ return (
+
+
+
+
+
{title}
+
{description}
+ {actionTo && actionLabel ? (
+
+
+
+ ) : null}
+ {children ?
{children}
: null}
+
+ )
+}
diff --git a/src/components/ui/FeatureTabs.tsx b/src/components/ui/FeatureTabs.tsx
new file mode 100644
index 0000000..4c48e92
--- /dev/null
+++ b/src/components/ui/FeatureTabs.tsx
@@ -0,0 +1,28 @@
+import { NavLink } from 'react-router-dom'
+
+export function FeatureTabs({
+ items,
+}: {
+ items: Array<{ to: string; label: string; end?: boolean }>
+}) {
+ return (
+
+ {items.map((item) => (
+
+ `rounded-xl px-3.5 py-2 text-sm transition ${
+ isActive
+ ? 'bg-white font-medium text-ink shadow-[0_1px_2px_rgba(23,32,51,0.06)]'
+ : 'text-ink-muted hover:text-ink'
+ }`
+ }
+ >
+ {item.label}
+
+ ))}
+
+ )
+}
diff --git a/src/components/ui/Field.tsx b/src/components/ui/Field.tsx
new file mode 100644
index 0000000..18daaf8
--- /dev/null
+++ b/src/components/ui/Field.tsx
@@ -0,0 +1,224 @@
+import { AlertCircle } from 'lucide-react'
+import {
+ forwardRef,
+ useId,
+ type InputHTMLAttributes,
+ type ReactNode,
+ type TextareaHTMLAttributes,
+} from 'react'
+import { SelectMenu, type SelectMenuOption } from './SelectMenu'
+
+const CONTROL_BASE =
+ 'w-full rounded-xl border bg-white text-sm text-ink outline-none transition placeholder:text-ink-subtle disabled:cursor-not-allowed disabled:bg-surface-subtle disabled:text-ink-muted'
+
+function controlClass(invalid?: boolean, extra = '') {
+ return `${CONTROL_BASE} ${
+ invalid
+ ? 'border-danger/50 focus:border-danger focus:ring-2 focus:ring-danger/15'
+ : 'border-line hover:border-line-strong focus:border-accent focus:ring-2 focus:ring-accent/15'
+ } ${extra}`
+}
+
+type FieldProps = {
+ label?: ReactNode
+ hint?: ReactNode
+ error?: string | null
+ required?: boolean
+ htmlFor?: string
+ children: ReactNode
+ className?: string
+}
+
+export function Field({
+ label,
+ hint,
+ error,
+ required,
+ htmlFor,
+ children,
+ className = '',
+}: FieldProps) {
+ return (
+
+ {label ? (
+
+ ) : null}
+ {children}
+ {error ? (
+
+
+ {error}
+
+ ) : hint ? (
+
{hint}
+ ) : null}
+
+ )
+}
+
+type InputProps = InputHTMLAttributes & {
+ invalid?: boolean
+ leadingIcon?: ReactNode
+}
+
+export const Input = forwardRef(function Input(
+ { className = '', invalid, leadingIcon, ...rest },
+ ref,
+) {
+ if (leadingIcon) {
+ return (
+
+
+ {leadingIcon}
+
+
+
+ )
+ }
+ return (
+
+ )
+})
+
+type TextareaProps = TextareaHTMLAttributes & { invalid?: boolean }
+
+export const Textarea = forwardRef(function Textarea(
+ { className = '', invalid, rows = 3, ...rest },
+ ref,
+) {
+ return (
+
+ )
+})
+
+type SelectProps = {
+ value: string
+ options: readonly SelectMenuOption[]
+ onChange: (value: string) => void
+ ariaLabel: string
+ placeholder?: string
+ disabled?: boolean
+ invalid?: boolean
+ className?: string
+ id?: string
+ required?: boolean
+}
+
+export function Select({
+ value,
+ options,
+ onChange,
+ ariaLabel,
+ placeholder,
+ disabled,
+ invalid,
+ className = '',
+}: SelectProps) {
+ return (
+
+ )
+}
+
+/** Label + control in one call for the common case. */
+export function LabeledInput({
+ label,
+ hint,
+ error,
+ required,
+ className,
+ ...rest
+}: InputProps & Pick) {
+ const id = useId()
+ return (
+
+
+
+ )
+}
+
+export function LabeledTextarea({
+ label,
+ hint,
+ error,
+ required,
+ className,
+ ...rest
+}: TextareaProps & Pick) {
+ const id = useId()
+ return (
+
+
+
+ )
+}
+
+export function LabeledSelect({
+ label,
+ hint,
+ error,
+ required,
+ className,
+ ...rest
+}: SelectProps & Pick) {
+ return (
+
+
+
+ )
+}
+
+export type { SelectMenuOption }
diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx
new file mode 100644
index 0000000..1b2746f
--- /dev/null
+++ b/src/components/ui/PageHeader.tsx
@@ -0,0 +1,41 @@
+import type { LucideIcon } from 'lucide-react'
+import type { ReactNode } from 'react'
+
+export function PageHeader({
+ title,
+ subtitle,
+ action,
+ icon: Icon,
+ kicker,
+ meta,
+}: {
+ title: string
+ subtitle?: string
+ action?: ReactNode
+ icon?: LucideIcon
+ kicker?: string
+ meta?: ReactNode
+}) {
+ return (
+
+
+ {Icon ? (
+
+
+
+ ) : null}
+
+ {kicker ? (
+
{kicker}
+ ) : null}
+
{title}
+ {subtitle ? (
+
{subtitle}
+ ) : null}
+ {meta ?
{meta}
: null}
+
+
+ {action ?
{action}
: null}
+
+ )
+}
diff --git a/src/components/ui/PageShell.tsx b/src/components/ui/PageShell.tsx
new file mode 100644
index 0000000..26694ed
--- /dev/null
+++ b/src/components/ui/PageShell.tsx
@@ -0,0 +1,29 @@
+import type { HTMLAttributes, ReactNode } from 'react'
+
+const WIDTHS = {
+ sm: 'max-w-2xl',
+ md: 'max-w-6xl',
+ lg: 'max-w-7xl',
+ full: 'max-w-[1600px]',
+} as const
+
+type Props = HTMLAttributes & {
+ children: ReactNode
+ width?: keyof typeof WIDTHS
+}
+
+export function PageShell({
+ children,
+ width = 'lg',
+ className = '',
+ ...rest
+}: Props) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/ui/SegmentedTabs.tsx b/src/components/ui/SegmentedTabs.tsx
new file mode 100644
index 0000000..7d8a459
--- /dev/null
+++ b/src/components/ui/SegmentedTabs.tsx
@@ -0,0 +1,36 @@
+export function SegmentedTabs({
+ items,
+ value,
+ onChange,
+ className = '',
+}: {
+ items: ReadonlyArray
+ value: T
+ onChange: (value: T) => void
+ className?: string
+}) {
+ return (
+
+ {items.map(([id, label]) => {
+ const active = value === id
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/ui/SelectMenu.tsx b/src/components/ui/SelectMenu.tsx
new file mode 100644
index 0000000..bf6f65e
--- /dev/null
+++ b/src/components/ui/SelectMenu.tsx
@@ -0,0 +1,354 @@
+import { Check, ChevronDown } from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
+import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+
+export type SelectMenuOption = {
+ value: string
+ label: string
+ description?: string
+ group?: string
+ icon?: LucideIcon
+ disabled?: boolean
+}
+
+type MenuPos = {
+ top: number
+ left: number
+ width: number
+ openUp: boolean
+ maxHeight: number
+}
+
+const PANEL_CLASS =
+ 'overflow-hidden rounded-2xl border border-line bg-white shadow-[0_12px_40px_-12px_rgba(23,32,51,0.28),0_4px_12px_-4px_rgba(23,32,51,0.12)]'
+
+export function SelectMenu({
+ value,
+ options,
+ onChange,
+ ariaLabel,
+ placeholder = 'انتخاب کنید',
+ disabled = false,
+ leading,
+ trailing,
+ footer,
+ size = 'md',
+ fullWidth = false,
+ matchTriggerWidth = true,
+ minMenuWidth = 200,
+ maxMenuWidth = 360,
+ triggerClassName = '',
+ invalid = false,
+ emptyLabel = 'موردی نیست',
+ iconOnly = false,
+ selectedValues,
+ closeOnSelect = true,
+ displayLabel,
+}: {
+ value: string
+ options: readonly SelectMenuOption[]
+ onChange: (value: string) => void
+ ariaLabel: string
+ placeholder?: string
+ disabled?: boolean
+ leading?: ReactNode
+ trailing?: ReactNode
+ footer?: ReactNode
+ size?: 'sm' | 'md' | 'chip'
+ fullWidth?: boolean
+ matchTriggerWidth?: boolean
+ minMenuWidth?: number
+ maxMenuWidth?: number
+ triggerClassName?: string
+ invalid?: boolean
+ emptyLabel?: string
+ iconOnly?: boolean
+ /** When set, checkmarks use this set instead of the single `value`. */
+ selectedValues?: readonly string[]
+ closeOnSelect?: boolean
+ /** Override the trigger text (e.g. first attached file while several are checked). */
+ displayLabel?: string
+}) {
+ const listId = useId()
+ const [open, setOpen] = useState(false)
+ const [pos, setPos] = useState(null)
+ const [highlight, setHighlight] = useState(-1)
+ const triggerRef = useRef(null)
+ const menuRef = useRef(null)
+
+ const selected = useMemo(
+ () => options.find((option) => option.value === value) || null,
+ [options, value],
+ )
+
+ const enabledIndexes = useMemo(
+ () => options.map((option, index) => (option.disabled ? -1 : index)).filter((index) => index >= 0),
+ [options],
+ )
+
+ function updatePosition() {
+ if (!triggerRef.current) return
+ const rect = triggerRef.current.getBoundingClientRect()
+ const width = Math.min(
+ maxMenuWidth,
+ Math.max(matchTriggerWidth ? rect.width : minMenuWidth, minMenuWidth),
+ )
+ const spaceBelow = window.innerHeight - rect.bottom - 12
+ const spaceAbove = rect.top - 12
+ const openUp = spaceBelow < 220 && spaceAbove > spaceBelow
+ const maxHeight = Math.min(320, Math.max(160, openUp ? spaceAbove : spaceBelow))
+ const left = Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)
+ setPos({
+ top: openUp ? rect.top - 6 : rect.bottom + 6,
+ left,
+ width,
+ openUp,
+ maxHeight,
+ })
+ }
+
+ useLayoutEffect(() => {
+ if (!open) return
+ updatePosition()
+ const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled)
+ setHighlight(selectedIndex >= 0 ? selectedIndex : enabledIndexes[0] ?? -1)
+ }, [open, options, value, enabledIndexes])
+
+ useEffect(() => {
+ if (!open) return
+ function onPointerDown(event: MouseEvent) {
+ const target = event.target as Node
+ if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) {
+ setOpen(false)
+ }
+ }
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ setOpen(false)
+ triggerRef.current?.focus()
+ }
+ }
+ function onReposition() {
+ updatePosition()
+ }
+ document.addEventListener('mousedown', onPointerDown)
+ document.addEventListener('keydown', onKeyDown)
+ window.addEventListener('resize', onReposition)
+ window.addEventListener('scroll', onReposition, true)
+ return () => {
+ document.removeEventListener('mousedown', onPointerDown)
+ document.removeEventListener('keydown', onKeyDown)
+ window.removeEventListener('resize', onReposition)
+ window.removeEventListener('scroll', onReposition, true)
+ }
+ }, [open])
+
+ useEffect(() => {
+ if (!open || highlight < 0) return
+ const item = menuRef.current?.querySelector(`[data-index="${highlight}"]`)
+ item?.scrollIntoView({ block: 'nearest' })
+ }, [highlight, open])
+
+ function selectValue(next: string) {
+ onChange(next)
+ if (!closeOnSelect) return
+ setOpen(false)
+ triggerRef.current?.focus()
+ }
+
+ function moveHighlight(delta: number) {
+ if (enabledIndexes.length === 0) return
+ const currentPos = enabledIndexes.indexOf(highlight)
+ const start = currentPos >= 0 ? currentPos : delta > 0 ? -1 : 0
+ const nextPos = (start + delta + enabledIndexes.length) % enabledIndexes.length
+ setHighlight(enabledIndexes[nextPos]!)
+ }
+
+ useEffect(() => {
+ if (!open) return
+ menuRef.current?.focus()
+ }, [open, pos])
+
+ function onTriggerKeyDown(event: ReactKeyboardEvent) {
+ if (disabled) return
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ setOpen(true)
+ }
+ }
+
+ function onMenuKeyDown(event: ReactKeyboardEvent) {
+ if (event.key === 'ArrowDown') {
+ event.preventDefault()
+ moveHighlight(1)
+ return
+ }
+ if (event.key === 'ArrowUp') {
+ event.preventDefault()
+ moveHighlight(-1)
+ return
+ }
+ if (event.key === 'Home') {
+ event.preventDefault()
+ setHighlight(enabledIndexes[0] ?? -1)
+ return
+ }
+ if (event.key === 'End') {
+ event.preventDefault()
+ setHighlight(enabledIndexes[enabledIndexes.length - 1] ?? -1)
+ return
+ }
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ const option = options[highlight]
+ if (option && !option.disabled) selectValue(option.value)
+ }
+ }
+
+ const isChip = size === 'chip'
+ const heightClass = isChip ? 'h-8' : size === 'sm' ? 'h-9' : 'h-10'
+ const shapeClass = isChip ? 'gap-1.5 rounded-full pr-2 pl-1.5' : 'gap-2 rounded-xl pr-3 pl-2.5'
+ const textClass = isChip ? 'text-[12px] font-medium' : 'text-sm'
+ const SelectedIcon = selected?.icon
+ const triggerLabel = displayLabel || (value && selected ? selected.label : placeholder)
+ const triggerMuted = !displayLabel && !(value && selected)
+
+ const menu =
+ open && pos
+ ? createPortal(
+
+
+ {options.length === 0 ? (
+
{emptyLabel}
+ ) : (
+ options.map((option, index) => {
+ const Icon = option.icon
+ const isSelected = selectedValues
+ ? selectedValues.includes(option.value)
+ : option.value === value
+ const isActive = index === highlight
+ const prevGroup = index > 0 ? options[index - 1]?.group : undefined
+ const showGroup = Boolean(option.group && option.group !== prevGroup)
+ return (
+
+ {showGroup ? (
+
+ {option.group}
+
+ ) : null}
+
+
+ )
+ })
+ )}
+
+ {footer ? (
+
{footer}
+ ) : null}
+
,
+ document.body,
+ )
+ : null
+
+ return (
+
+
+ {menu}
+
+ )
+}
+
+export const selectMenuPanelClass = PANEL_CLASS
diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx
new file mode 100644
index 0000000..5124a5c
--- /dev/null
+++ b/src/components/ui/Skeleton.tsx
@@ -0,0 +1,48 @@
+export function Skeleton({ className = '' }: { className?: string }) {
+ return
+}
+
+export function TableSkeleton({ rows = 6 }: { rows?: number }) {
+ return (
+
+
+
+
+ {Array.from({ length: rows }).map((_, index) => (
+
+
+
+
+
+
+ ))}
+
+ )
+}
+
+export function CardGridSkeleton({
+ count = 4,
+ className = 'grid gap-4 md:grid-cols-2 xl:grid-cols-3',
+}: {
+ count?: number
+ className?: string
+}) {
+ return (
+
+ {Array.from({ length: count }).map((_, index) => (
+
+ ))}
+
+ )
+}
diff --git a/src/components/ui/StatusBadge.tsx b/src/components/ui/StatusBadge.tsx
new file mode 100644
index 0000000..7dac57a
--- /dev/null
+++ b/src/components/ui/StatusBadge.tsx
@@ -0,0 +1,83 @@
+export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warn' | 'danger'
+
+const TONES: Record = {
+ neutral: 'bg-surface-subtle text-ink-muted ring-line',
+ accent: 'bg-accent-soft text-accent ring-accent/15',
+ success: 'bg-success-soft text-success ring-success/15',
+ warn: 'bg-warn-soft text-warn ring-warn/20',
+ danger: 'bg-danger-soft text-danger ring-danger/15',
+}
+
+export function Badge({
+ children,
+ tone = 'neutral',
+ className = '',
+}: {
+ children: React.ReactNode
+ tone?: BadgeTone
+ className?: string
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+const STATUS_TONES: Record = {
+ ready: 'success',
+ complete: 'success',
+ published: 'success',
+ processing: 'accent',
+ draft: 'neutral',
+ needs_review: 'warn',
+ inferred: 'accent',
+ confirmed: 'success',
+ unknown: 'neutral',
+ partial: 'warn',
+ archived: 'neutral',
+ insufficient: 'neutral',
+ failed: 'danger',
+ data_only: 'neutral',
+ understanding: 'accent',
+ draft_ready: 'warn',
+ TODO: 'neutral',
+ RUNNING: 'accent',
+ FINISHED: 'success',
+ FAILED: 'danger',
+ todo: 'neutral',
+ running: 'accent',
+ finished: 'success',
+}
+
+const LABELS: Record = {
+ ready: 'آماده',
+ complete: 'کامل',
+ published: 'آماده',
+ processing: 'در حال پردازش',
+ draft: 'پیشنویس',
+ needs_review: 'نیازمند بررسی',
+ inferred: 'استنتاجشده',
+ confirmed: 'تأییدشده',
+ unknown: 'نامشخص',
+ partial: 'بخشی قابل استفاده',
+ archived: 'آرشیو',
+ insufficient: 'داده ناکافی',
+ failed: 'خطا',
+ data_only: 'فقط داده',
+ understanding: 'در حال شناخت',
+ draft_ready: 'مدل اولیه آماده',
+ TODO: 'در انتظار',
+ RUNNING: 'در حال پردازش',
+ FINISHED: 'آماده',
+ FAILED: 'خطا',
+ todo: 'در انتظار',
+ running: 'در حال پردازش',
+ finished: 'آماده',
+}
+
+export function StatusBadge({ status, label }: { status: string; label?: string }) {
+ return {label || LABELS[status] || status}
+}
diff --git a/src/components/ui/Switch.tsx b/src/components/ui/Switch.tsx
new file mode 100644
index 0000000..88174a3
--- /dev/null
+++ b/src/components/ui/Switch.tsx
@@ -0,0 +1,62 @@
+type Props = {
+ checked: boolean
+ onChange: (checked: boolean) => void
+ label: string
+ /** Renders the label next to the control instead of only for screen readers. */
+ showLabel?: boolean
+ description?: string
+ disabled?: boolean
+ size?: 'sm' | 'md'
+}
+
+// Class strings must stay literal so Tailwind's scanner can see them.
+const SIZES = {
+ sm: { track: 'h-5 w-9', thumb: 'h-3.5 w-3.5', on: '-translate-x-4', off: 'translate-x-0' },
+ md: { track: 'h-6 w-11', thumb: 'h-4 w-4', on: '-translate-x-5', off: 'translate-x-0' },
+} as const
+
+export function Switch({
+ checked,
+ onChange,
+ label,
+ showLabel = false,
+ description,
+ disabled,
+ size = 'sm',
+}: Props) {
+ const style = SIZES[size]
+
+ const control = (
+
+ )
+
+ if (!showLabel) return control
+
+ return (
+
+ )
+}
diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx
new file mode 100644
index 0000000..14ed307
--- /dev/null
+++ b/src/components/ui/Toast.tsx
@@ -0,0 +1,40 @@
+import { CheckCircle2, X } from 'lucide-react'
+import { useEffect } from 'react'
+
+export function Toast({
+ message,
+ onDismiss,
+ duration = 3500,
+}: {
+ message: string | null
+ onDismiss: () => void
+ duration?: number
+}) {
+ useEffect(() => {
+ if (!message) return
+ const timer = window.setTimeout(onDismiss, duration)
+ return () => window.clearTimeout(timer)
+ }, [message, duration, onDismiss])
+
+ if (!message) return null
+
+ return (
+
+
+
+ {message}
+
+
+
+ )
+}
diff --git a/src/components/ui/ToastProvider.tsx b/src/components/ui/ToastProvider.tsx
new file mode 100644
index 0000000..03ded15
--- /dev/null
+++ b/src/components/ui/ToastProvider.tsx
@@ -0,0 +1,129 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+ type PropsWithChildren,
+} from 'react'
+import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react'
+
+export type ToastTone = 'success' | 'error' | 'warn' | 'info'
+
+type ToastItem = {
+ id: number
+ message: string
+ tone: ToastTone
+ action?: { label: string; onSelect: () => void }
+}
+
+type PushOptions = {
+ tone?: ToastTone
+ duration?: number
+ action?: ToastItem['action']
+}
+
+type ToastApi = {
+ push: (message: string, options?: PushOptions) => void
+ success: (message: string, options?: Omit) => void
+ error: (message: string, options?: Omit) => void
+ dismiss: (id: number) => void
+}
+
+const ToastContext = createContext(null)
+
+const TONES: Record = {
+ success: { icon: CheckCircle2, className: 'text-success' },
+ error: { icon: XCircle, className: 'text-danger' },
+ warn: { icon: AlertTriangle, className: 'text-warn' },
+ info: { icon: Info, className: 'text-info' },
+}
+
+export function ToastProvider({ children }: PropsWithChildren) {
+ const [items, setItems] = useState([])
+ const nextId = useRef(1)
+
+ const dismiss = useCallback((id: number) => {
+ setItems((list) => list.filter((item) => item.id !== id))
+ }, [])
+
+ const push = useCallback(
+ (message: string, options?: PushOptions) => {
+ const id = nextId.current++
+ setItems((list) => [
+ ...list.slice(-2),
+ { id, message, tone: options?.tone ?? 'success', action: options?.action },
+ ])
+ window.setTimeout(() => dismiss(id), options?.duration ?? 4000)
+ },
+ [dismiss],
+ )
+
+ const api = useMemo(
+ () => ({
+ push,
+ dismiss,
+ success: (message, options) => push(message, { ...options, tone: 'success' }),
+ error: (message, options) => push(message, { ...options, tone: 'error', duration: 6000 }),
+ }),
+ [push, dismiss],
+ )
+
+ return (
+
+ {children}
+
+ {items.map((item) => {
+ const { icon: Icon, className } = TONES[item.tone]
+ return (
+
+
+ {item.message}
+ {item.action ? (
+
+ ) : null}
+
+
+ )
+ })}
+
+
+ )
+}
+
+/**
+ * Falls back to a no-op outside a provider so components stay renderable in
+ * isolation (tests, storybook-style previews).
+ */
+export function useToast(): ToastApi {
+ const context = useContext(ToastContext)
+ return (
+ context ?? {
+ push: () => undefined,
+ success: () => undefined,
+ error: () => undefined,
+ dismiss: () => undefined,
+ }
+ )
+}
diff --git a/src/components/ui/Toolbar.tsx b/src/components/ui/Toolbar.tsx
new file mode 100644
index 0000000..ca15de5
--- /dev/null
+++ b/src/components/ui/Toolbar.tsx
@@ -0,0 +1,98 @@
+import { Search, X } from 'lucide-react'
+import type { PropsWithChildren } from 'react'
+import { SelectMenu } from './SelectMenu'
+
+export function Toolbar({ children }: PropsWithChildren) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function ToolbarSpacer() {
+ return
+}
+
+type Option = { value: string; label: string }
+
+export function FilterSelect({
+ label,
+ ariaLabel,
+ value,
+ options,
+ onChange,
+ minWidth = 'min-w-[9rem]',
+}: {
+ label: string
+ ariaLabel: string
+ value: string
+ options: Option[]
+ onChange: (value: string) => void
+ minWidth?: string
+}) {
+ const active = value !== 'all' && value !== ''
+ return (
+
+ {label}
+
+
+ )
+}
+
+export function SearchField({
+ ariaLabel,
+ value,
+ placeholder,
+ onChange,
+ onCommit,
+}: {
+ ariaLabel: string
+ value: string
+ placeholder?: string
+ onChange: (value: string) => void
+ onCommit: (value: string) => void
+}) {
+ return (
+
+
+ onChange(event.target.value)}
+ onBlur={() => onCommit(value.trim())}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') onCommit(value.trim())
+ }}
+ className="h-9 w-56 rounded-xl border border-line bg-white pr-9 pl-8 text-sm text-ink outline-none transition placeholder:text-ink-muted/70 focus:border-accent/40 focus:shadow-[0_0_0_3px_rgba(37,99,235,0.08)]"
+ />
+ {value ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx
new file mode 100644
index 0000000..b238eea
--- /dev/null
+++ b/src/components/ui/Tooltip.tsx
@@ -0,0 +1,88 @@
+import { useId, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+
+type Placement = 'top' | 'bottom' | 'start' | 'end'
+
+type Props = {
+ label: ReactNode
+ placement?: Placement
+ children: ReactElement<{
+ onMouseEnter?: (event: unknown) => void
+ onMouseLeave?: (event: unknown) => void
+ onFocus?: (event: unknown) => void
+ onBlur?: (event: unknown) => void
+ 'aria-describedby'?: string
+ }>
+}
+
+const GAP = 8
+
+/** Hover/focus tooltip rendered in a portal so it escapes overflow containers. */
+export function Tooltip({ label, placement = 'bottom', children }: Props) {
+ const id = useId()
+ const [open, setOpen] = useState(false)
+ const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
+ const wrapperRef = useRef(null)
+ const bubbleRef = useRef(null)
+
+ useLayoutEffect(() => {
+ if (!open) return
+ const anchor = wrapperRef.current?.firstElementChild ?? wrapperRef.current
+ const bubble = bubbleRef.current
+ if (!anchor || !bubble) return
+
+ const a = anchor.getBoundingClientRect()
+ const b = bubble.getBoundingClientRect()
+ let top = a.bottom + GAP
+ let left = a.left + a.width / 2 - b.width / 2
+
+ if (placement === 'top') top = a.top - b.height - GAP
+ if (placement === 'start' || placement === 'end') {
+ top = a.top + a.height / 2 - b.height / 2
+ const isRtl = document.documentElement.dir === 'rtl'
+ const toLeft = placement === (isRtl ? 'end' : 'start')
+ left = toLeft ? a.left - b.width - GAP : a.right + GAP
+ }
+
+ const margin = 8
+ left = Math.min(Math.max(margin, left), window.innerWidth - b.width - margin)
+ top = Math.min(Math.max(margin, top), window.innerHeight - b.height - margin)
+ setPos({ top, left })
+ }, [open, placement])
+
+ const show = () => setOpen(true)
+ const hide = () => setOpen(false)
+
+ return (
+ <>
+
+ {children}
+
+ {open
+ ? createPortal(
+
+ {label}
+
,
+ document.body,
+ )
+ : null}
+ >
+ )
+}
diff --git a/src/components/ui/useFocusTrap.ts b/src/components/ui/useFocusTrap.ts
new file mode 100644
index 0000000..eaffb94
--- /dev/null
+++ b/src/components/ui/useFocusTrap.ts
@@ -0,0 +1,66 @@
+import { useEffect, type RefObject } from 'react'
+
+const FOCUSABLE = [
+ 'a[href]',
+ 'button:not([disabled])',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ '[tabindex]:not([tabindex="-1"])',
+].join(',')
+
+/**
+ * Keeps keyboard focus inside an overlay while it is open and restores it to the
+ * trigger on close, so dialogs and drawers behave like native modals.
+ */
+export function useFocusTrap(ref: RefObject, active: boolean) {
+ useEffect(() => {
+ if (!active) return
+ const container = ref.current
+ if (!container) return
+
+ const previouslyFocused = document.activeElement as HTMLElement | null
+ const initial = container.querySelector(FOCUSABLE)
+ ;(initial ?? container).focus({ preventScroll: true })
+
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key !== 'Tab') return
+ const node = ref.current
+ if (!node) return
+ const items = Array.from(node.querySelectorAll(FOCUSABLE)).filter(
+ (item) => item.offsetParent !== null || item === document.activeElement,
+ )
+ if (items.length === 0) {
+ event.preventDefault()
+ return
+ }
+ const first = items[0]
+ const last = items[items.length - 1]
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault()
+ last.focus()
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault()
+ first.focus()
+ }
+ }
+
+ document.addEventListener('keydown', onKeyDown)
+ return () => {
+ document.removeEventListener('keydown', onKeyDown)
+ previouslyFocused?.focus?.({ preventScroll: true })
+ }
+ }, [ref, active])
+}
+
+/** Prevents the page behind an overlay from scrolling while it is open. */
+export function useBodyScrollLock(active: boolean) {
+ useEffect(() => {
+ if (!active) return
+ const previous = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => {
+ document.body.style.overflow = previous
+ }
+ }, [active])
+}
diff --git a/src/contracts/chat.ts b/src/contracts/chat.ts
new file mode 100644
index 0000000..24db796
--- /dev/null
+++ b/src/contracts/chat.ts
@@ -0,0 +1,21 @@
+import type { ResourceRef } from './common'
+
+export interface ChatRequest {
+ conversation_id?: string
+ message: string
+ resources: ResourceRef[]
+ skill_id?: string
+ model_id?: string
+ mode?: 'auto' | 'analysis'
+ parent_run_id?: string
+ temperature?: number
+ max_new_tokens?: number
+ reasoning_mode?: 'fast' | 'medium' | 'deep'
+}
+
+export interface ChatResponse {
+ run_id: string
+ conversation_id: string
+ message_id: string
+ status: string
+}
diff --git a/src/contracts/common.ts b/src/contracts/common.ts
new file mode 100644
index 0000000..54f1bf5
--- /dev/null
+++ b/src/contracts/common.ts
@@ -0,0 +1,42 @@
+export type ResourceType = 'file' | 'datasource' | 'knowledge'
+
+export interface ResourceRef {
+ id: string
+ type: ResourceType
+ name?: string
+}
+
+export interface ResourceView extends ResourceRef {
+ name: string
+ status?: string
+ icon?: string
+ subtitle?: string
+}
+
+export const LIVE_RUN_STATUSES = [
+ 'created',
+ 'queued',
+ 'preparing',
+ 'running',
+ 'waiting_for_user',
+ 'executing',
+ 'streaming',
+] as const
+
+export function isLiveRunStatus(status?: string | null): boolean {
+ return Boolean(status && (LIVE_RUN_STATUSES as readonly string[]).includes(status))
+}
+
+export type RunStatus =
+ | 'created'
+ | 'queued'
+ | 'preparing'
+ | 'running'
+ | 'waiting_for_user'
+ | 'executing'
+ | 'streaming'
+ | 'completed'
+ | 'failed'
+ | 'cancelled'
+
+export type FileStatus = 'queued' | 'uploading' | 'processing' | 'ready' | 'failed' | 'cancelled'
diff --git a/src/contracts/errors.ts b/src/contracts/errors.ts
new file mode 100644
index 0000000..9a6fca0
--- /dev/null
+++ b/src/contracts/errors.ts
@@ -0,0 +1,25 @@
+export interface KamidsoError {
+ code: string
+ message: string
+ retryable?: boolean
+ run_id?: string
+}
+
+export const ERROR_MESSAGES: Record = {
+ ENGINE_UNAVAILABLE: 'موتور تحلیل در حال حاضر در دسترس نیست.',
+ FILE_TOO_LARGE: 'حجم فایل بیشتر از حد مجاز است.',
+ RESOURCE_ACCESS_DENIED: 'دستریرسی به این منبع برای شما مجاز نیست.',
+ NOT_FOUND: 'مورد درخواستی یافت نشد.',
+ UPLOAD_FAILED: 'بارگذاری فایل ناموفق بود.',
+}
+
+export function normalizeError(err: unknown): KamidsoError {
+ if (err && typeof err === 'object') {
+ const obj = err as Record
+ const code = String(obj.code ?? obj.errorCode ?? 'UNKNOWN')
+ const message =
+ String(obj.message ?? obj.err_msg ?? ERROR_MESSAGES[code] ?? 'خطایی رخ داد.')
+ return { code, message, retryable: Boolean(obj.retryable) }
+ }
+ return { code: 'UNKNOWN', message: 'خطایی رخ داد.' }
+}
diff --git a/src/contracts/event.ts b/src/contracts/event.ts
new file mode 100644
index 0000000..d919f78
--- /dev/null
+++ b/src/contracts/event.ts
@@ -0,0 +1,40 @@
+export const CANONICAL_EVENT_TYPES = [
+ 'run.started',
+ 'run.preparing',
+ 'run.created',
+ 'plan.updated',
+ 'plan.update',
+ 'step.started',
+ 'step.start',
+ 'step.updated',
+ 'step.meta',
+ 'step.output',
+ 'step.chunk',
+ 'step.completed',
+ 'step.done',
+ 'step.failed',
+ 'question.requested',
+ 'question.asked',
+ 'question.resolved',
+ 'question.replied',
+ 'question.rejected',
+ 'artifact.created',
+ 'answer.delta',
+ 'answer.completed',
+ 'answer.started',
+ 'run.completed',
+ 'run.failed',
+ 'run.cancelled',
+ 'final',
+ 'done',
+ 'context.status',
+] as const
+
+export type KamidsoEventType = (typeof CANONICAL_EVENT_TYPES)[number]
+
+export interface KamidsoRunEvent {
+ type: string
+ run_id?: string
+ sequence?: number
+ [key: string]: unknown
+}
diff --git a/src/contracts/file.ts b/src/contracts/file.ts
new file mode 100644
index 0000000..acee4be
--- /dev/null
+++ b/src/contracts/file.ts
@@ -0,0 +1,31 @@
+import type { FileStatus } from './common'
+
+export interface KamidsoFile {
+ id: string
+ name: string
+ size_bytes: number
+ mime_type: string
+ status: 'processing' | 'ready' | 'failed'
+ created_at: string
+}
+
+export interface FilePreview {
+ kind: 'spreadsheet' | 'table' | 'text' | 'document' | 'image' | 'json' | 'unsupported' | 'binary' | 'tabular'
+ metadata: Record
+ data?: unknown
+ type?: string
+ columns?: string[]
+ rows?: unknown[][]
+ sample?: unknown
+}
+
+export interface WorkspaceFileSnapshot {
+ file_id: string
+ name: string
+ size: number
+ media_type: string
+ kind: string
+ status: FileStatus | 'uploading' | 'inspecting' | 'preview_failed' | 'deleted'
+ ordinal: number
+ error_code?: string | null
+}
diff --git a/src/contracts/index.ts b/src/contracts/index.ts
new file mode 100644
index 0000000..2db98a2
--- /dev/null
+++ b/src/contracts/index.ts
@@ -0,0 +1,7 @@
+export * from './common'
+export * from './chat'
+export * from './event'
+export * from './run'
+export * from './file'
+export * from './errors'
+export * from './knowledge'
diff --git a/src/contracts/knowledge.ts b/src/contracts/knowledge.ts
new file mode 100644
index 0000000..9265818
--- /dev/null
+++ b/src/contracts/knowledge.ts
@@ -0,0 +1,65 @@
+export type KnowledgeDocStatus = 'TODO' | 'RUNNING' | 'FINISHED' | 'FAILED' | string
+export type KnowledgeSpaceStatus = 'ready' | 'processing' | 'partial' | 'failed' | string
+
+export interface KnowledgeChunkSettings {
+ strategy?: string
+ chunk_size?: number
+ chunk_overlap?: number
+ topk?: number
+ recall_score?: number
+}
+
+export interface KnowledgeSpace {
+ id: string
+ name: string
+ description?: string
+ status: KnowledgeSpaceStatus
+ vector_type?: string
+ document_count: number
+ chunk_count: number
+ context?: {
+ embedding?: KnowledgeChunkSettings
+ }
+}
+
+export interface KnowledgeDocument {
+ id: string
+ name: string
+ doc_type: string
+ status: KnowledgeDocStatus
+ chunk_size: number
+ result?: string
+ summary?: string
+ content?: string
+ file_id?: string | null
+}
+
+export interface KnowledgeChunk {
+ id: string
+ document_id: string
+ doc_name?: string
+ content: string
+ meta_info?: string
+}
+
+export interface KnowledgeChunkPage {
+ items: KnowledgeChunk[]
+ total: number
+ page: number
+ page_size: number
+}
+
+export interface KnowledgeRetrieveHit {
+ chunk_id?: string
+ content: string
+ score: number
+ doc_name?: string
+}
+
+export interface KnowledgeSourceInput {
+ type: 'file' | 'url' | 'text'
+ file_id?: string
+ url?: string
+ text?: string
+ title?: string
+}
diff --git a/src/contracts/report.ts b/src/contracts/report.ts
new file mode 100644
index 0000000..0b9d181
--- /dev/null
+++ b/src/contracts/report.ts
@@ -0,0 +1,245 @@
+export const REPORT_SCHEMA_VERSION = 2
+export const REPORT_SCHEMA_VERSIONS = [1, 2] as const
+export const MAX_TABLE_ROWS = 50
+
+export type ChartType = 'bar' | 'line' | 'area' | 'pie' | 'donut' | 'scatter'
+export type CalloutVariant = 'info' | 'success' | 'warning' | 'danger'
+export type Trend = 'up' | 'down' | 'flat'
+export type TextFormat = 'plain' | 'markdown'
+
+export interface ReportBlockBase {
+ id?: string
+ span?: number
+ pageBreakBefore?: boolean
+}
+
+export interface HeadingBlock extends ReportBlockBase {
+ type: 'heading'
+ level: 1 | 2 | 3
+ text: string
+}
+
+export interface TextBlock extends ReportBlockBase {
+ type: 'text'
+ content: string
+ format?: TextFormat
+}
+
+export interface KpiChange {
+ value?: string | number
+ direction?: Trend
+ label?: string
+}
+
+export interface KpiItem {
+ label: string
+ value?: string | number
+ formatted_value?: string
+ unit?: string
+ delta?: string
+ deltaLabel?: string
+ trend?: Trend
+ change?: KpiChange
+}
+
+export interface KpiBlock extends ReportBlockBase, KpiItem {
+ type: 'kpi'
+}
+
+export interface KpiGroupBlock extends ReportBlockBase {
+ type: 'kpi_group'
+ items: KpiItem[]
+}
+
+export interface TableColumn {
+ field?: string
+ label?: string
+ title?: string
+ dataIndex?: string
+ key?: string
+}
+
+export interface TableBlock extends ReportBlockBase {
+ type: 'table'
+ title?: string
+ dataset?: string
+ columns: Array
+ rows: unknown[]
+}
+
+export interface ChartSeries {
+ field: string
+ label?: string
+}
+
+export interface ChartBlock extends ReportBlockBase {
+ type: 'chart'
+ chartType?: ChartType
+ title?: string
+ dataset?: string
+ data?: Record[]
+ x: string
+ /** V1 single series; V2 prefers series[] */
+ y?: string | string[]
+ series?: ChartSeries[]
+ stacked?: boolean
+ legend?: boolean
+ showGrid?: boolean
+ showTooltip?: boolean
+ height?: number
+ xAxisLabel?: string
+ yAxisLabel?: string
+ valueFormat?: string
+}
+
+export interface CalloutBlock extends ReportBlockBase {
+ type: 'callout'
+ variant?: CalloutVariant
+ title?: string
+ text: string
+ content?: string
+}
+
+export interface ImageBlock extends ReportBlockBase {
+ type: 'image'
+ src: string
+ file_id?: string
+ artifact_id?: string
+ title?: string
+ caption?: string
+ alt?: string
+}
+
+export interface QuoteBlock extends ReportBlockBase {
+ type: 'quote'
+ content: string
+ source?: string
+}
+
+export interface DividerBlock extends ReportBlockBase {
+ type: 'divider'
+}
+
+export interface SpacerBlock extends ReportBlockBase {
+ type: 'spacer'
+ size?: number | string
+}
+
+export type ReportBlock =
+ | HeadingBlock
+ | TextBlock
+ | KpiBlock
+ | KpiGroupBlock
+ | TableBlock
+ | ChartBlock
+ | CalloutBlock
+ | ImageBlock
+ | QuoteBlock
+ | DividerBlock
+ | SpacerBlock
+
+export type ReportDataset = Record[] | { ref: string }
+
+export interface KamidsoReport {
+ version: number
+ report_id?: string
+ title: string
+ subtitle?: string
+ description?: string
+ locale?: string
+ summary?: string
+ datasets?: Record
+ blocks: ReportBlock[]
+ metadata?: Record
+ warnings?: string[]
+}
+
+export function isKamidsoReport(value: unknown): value is KamidsoReport {
+ if (!value || typeof value !== 'object') return false
+ const report = value as KamidsoReport
+ return (
+ (report.version === 1 || report.version === 2) &&
+ typeof report.title === 'string' &&
+ Array.isArray(report.blocks) &&
+ report.blocks.length > 0
+ )
+}
+
+export function resolveDatasetRows(
+ report: KamidsoReport,
+ datasetName?: string,
+): Record[] {
+ if (!datasetName || !report.datasets) return []
+ const entry = report.datasets[datasetName]
+ if (Array.isArray(entry)) return entry as Record[]
+ return []
+}
+
+export function resolveChartData(block: ChartBlock, report?: KamidsoReport): Record[] {
+ if (Array.isArray(block.data) && block.data.length) return block.data
+ if (block.dataset && report) return resolveDatasetRows(report, block.dataset)
+ return Array.isArray(block.data) ? block.data : []
+}
+
+export function normalizeChartSeries(block: ChartBlock): ChartSeries[] {
+ if (Array.isArray(block.series) && block.series.length) {
+ return block.series
+ .map((entry): ChartSeries | null => {
+ if (typeof entry === 'string') return { field: entry, label: entry }
+ const field = String(entry.field || '').trim()
+ if (!field) return null
+ return { field, label: entry.label || field }
+ })
+ .filter((x): x is ChartSeries => x !== null)
+ }
+ if (Array.isArray(block.y)) {
+ return block.y.filter(Boolean).map((field) => ({ field: String(field), label: String(field) }))
+ }
+ if (typeof block.y === 'string' && block.y.trim()) {
+ return [{ field: block.y.trim(), label: block.y.trim() }]
+ }
+ return [{ field: 'value', label: 'value' }]
+}
+
+export function normalizeChartSpec(
+ block: ChartBlock,
+ report?: KamidsoReport,
+): Record {
+ const series = normalizeChartSeries(block)
+ const data = resolveChartData(block, report)
+ return {
+ chartType: block.chartType ?? 'bar',
+ title: block.title,
+ data,
+ x: block.x || 'label',
+ y: series.length === 1 ? series[0].field : series.map((s) => s.field),
+ series,
+ stacked: block.stacked === true,
+ legend: block.legend,
+ showGrid: block.showGrid,
+ height: block.height,
+ xAxisLabel: block.xAxisLabel,
+ yAxisLabel: block.yAxisLabel,
+ valueFormat: block.valueFormat,
+ }
+}
+
+export function spanClass(span?: number): string {
+ const n = !span || span < 1 || span > 12 ? 12 : span
+ // Mobile always full width; md+ uses 12-col fractions.
+ const map: Record = {
+ 1: 'col-span-12 md:col-span-1',
+ 2: 'col-span-12 md:col-span-2',
+ 3: 'col-span-12 md:col-span-3',
+ 4: 'col-span-12 md:col-span-4',
+ 5: 'col-span-12 md:col-span-5',
+ 6: 'col-span-12 md:col-span-6',
+ 7: 'col-span-12 md:col-span-7',
+ 8: 'col-span-12 md:col-span-8',
+ 9: 'col-span-12 md:col-span-9',
+ 10: 'col-span-12 md:col-span-10',
+ 11: 'col-span-12 md:col-span-11',
+ 12: 'col-span-12',
+ }
+ return map[n] || map[12]
+}
diff --git a/src/contracts/run.ts b/src/contracts/run.ts
new file mode 100644
index 0000000..5e9350c
--- /dev/null
+++ b/src/contracts/run.ts
@@ -0,0 +1,72 @@
+import type { ResourceRef, RunStatus } from './common'
+import type { KamidsoError } from './errors'
+
+export type ExecutionStepType =
+ | 'planning'
+ | 'file_inspection'
+ | 'database_query'
+ | 'knowledge_search'
+ | 'code_execution'
+ | 'tool_execution'
+ | 'artifact_generation'
+ | 'finalization'
+
+export interface ExecutionStep {
+ id: string
+ type?: ExecutionStepType | string
+ title?: string
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'done'
+ detail?: string
+ phase?: string
+ action?: string
+ actionInput?: unknown
+ started_at?: string
+ completed_at?: string
+ output?: unknown
+ step?: number
+}
+
+export interface PlanTask {
+ content: string
+ status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
+ priority?: 'high' | 'medium' | 'low'
+}
+
+export type ArtifactType =
+ | 'table'
+ | 'chart'
+ | 'file'
+ | 'image'
+ | 'markdown'
+ | 'html'
+ | 'json'
+ | 'code'
+ | 'report'
+
+export interface KamidsoArtifact {
+ id: string
+ type: ArtifactType
+ name: string
+ content: unknown
+ filePath?: string
+}
+
+export interface PendingQuestion {
+ id: string
+ runId?: string
+ question: string
+ options?: Array<{ id: string; label: string }>
+}
+
+export interface KamidsoRun {
+ id: string
+ conversation_id: string
+ status: RunStatus
+ input?: { message?: string }
+ resources?: ResourceRef[]
+ steps?: ExecutionStep[]
+ artifacts?: KamidsoArtifact[]
+ answer?: string
+ question?: PendingQuestion
+ error?: KamidsoError
+}
diff --git a/src/e2e/golden-scenario-1.excel-analysis.e2e.test.tsx b/src/e2e/golden-scenario-1.excel-analysis.e2e.test.tsx
new file mode 100644
index 0000000..c8fc462
--- /dev/null
+++ b/src/e2e/golden-scenario-1.excel-analysis.e2e.test.tsx
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { runReducer } from '../features/assistant/stream/eventReducer'
+import { initialRunState } from '../features/assistant/state/runState'
+
+/** Golden scenario 1: Upload Excel → plan → code step → final answer (reducer-level). */
+describe('golden scenario 1 — excel analysis flow', () => {
+ it('reconstructs plan, step, and answer from canonical events', () => {
+ let state = runReducer(initialRunState(), { type: 'RUN_CREATED', runId: 'run_1', viewId: 'v1' })
+
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'plan.updated', tasks: [{ content: 'بررسی ساختار فایل', status: 'in_progress' }] },
+ })
+ expect(state.plan[0]?.content).toContain('بررسی')
+
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'step.started', id: 's1', title: 'تحلیل داده', step_type: 'code_execution' },
+ })
+ expect(state.executionByViewId.v1?.steps[0]?.title).toBe('تحلیل داده')
+
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'answer.completed', content: 'حقوق مصطفی ۱۲٬۰۰۰٬۰۰۰ تومان است.' },
+ })
+ expect(state.answer).toContain('حقوق')
+ expect(state.status).toBe('completed')
+ })
+})
diff --git a/src/e2e/golden-scenario-5.clarification.e2e.test.tsx b/src/e2e/golden-scenario-5.clarification.e2e.test.tsx
new file mode 100644
index 0000000..5a6d41f
--- /dev/null
+++ b/src/e2e/golden-scenario-5.clarification.e2e.test.tsx
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+import { runReducer } from '../features/assistant/stream/eventReducer'
+import { initialRunState } from '../features/assistant/state/runState'
+
+describe('golden scenario 5 — clarification', () => {
+ it('enters waiting_for_user on question.requested', () => {
+ const state = runReducer(initialRunState(), {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: {
+ type: 'question.requested',
+ id: 'q1',
+ run_id: 'run_1',
+ question: 'منظورتان کدام بازه زمانی است؟',
+ },
+ })
+ expect(state.status).toBe('waiting_for_user')
+ expect(state.question?.question).toContain('بازه')
+ })
+})
diff --git a/src/features/assistant/adapters/historyToMessages.test.ts b/src/features/assistant/adapters/historyToMessages.test.ts
new file mode 100644
index 0000000..c80ac7e
--- /dev/null
+++ b/src/features/assistant/adapters/historyToMessages.test.ts
@@ -0,0 +1,188 @@
+import { describe, expect, it } from 'vitest'
+import { collectTurnInputFiles, collectPinnedResources, historyToMessages } from './historyToMessages'
+import type { ChatMessage } from '../types/chat'
+
+describe('historyToMessages', () => {
+ it('pairs stored user/assistant messages and synthesizes a live view', () => {
+ const mapped = historyToMessages(
+ [
+ { id: 'm1', role: 'user', content: 'گزارش بده', resource_refs: [{ id: 'f1', type: 'file', name: 'a.xlsx' }] },
+ { id: 'm2', role: 'assistant', content: 'خلاصه قبلی' },
+ { id: 'm3', role: 'user', content: 'ادامه بده' },
+ ],
+ { id: 'run_live', status: 'running' },
+ )
+
+ expect(mapped).toHaveLength(4)
+ expect(mapped[0].role).toBe('human')
+ expect(mapped[0].attachedFiles?.[0]?.file_id).toBe('f1')
+ expect(mapped[1].role).toBe('view')
+ expect(mapped[1].context).toBe('خلاصه قبلی')
+ expect(mapped[2].role).toBe('human')
+ expect(mapped[3]).toMatchObject({
+ id: 'view-run_live',
+ role: 'view',
+ thinking: true,
+ context: '',
+ })
+ })
+
+ it('copies latest-run file resources onto the last user message when refs are missing', () => {
+ const mapped = historyToMessages(
+ [
+ { id: 'm1', role: 'user', content: 'ده مشتری برتر را نشان بده' },
+ { id: 'm2', role: 'assistant', content: 'خلاصه' },
+ ],
+ {
+ id: 'run_1',
+ status: 'completed',
+ resources: [{ resource_id: 'file_book1', resource_type: 'file', resource_name: 'Book1 (1)' }],
+ },
+ )
+
+ expect(mapped[0].attachedFiles?.[0]).toMatchObject({ file_id: 'file_book1', name: 'Book1 (1)' })
+ })
+
+ it('maps knowledge and datasource refs onto the user turn', () => {
+ const mapped = historyToMessages([
+ {
+ id: 'm1',
+ role: 'user',
+ content: 'ویراگلد چیست؟',
+ resource_refs: [
+ { id: 'kb_1', type: 'knowledge', name: 'پاسخ فنی واحد معاملات' },
+ { id: 'ds_1', type: 'datasource', name: 'sales-db' },
+ { id: 'ds_2', type: 'datasource', name: 'ops-mongo' },
+ ],
+ },
+ ])
+
+ expect(mapped[0].attachedKnowledge).toEqual({ id: 'kb_1', name: 'پاسخ فنی واحد معاملات' })
+ expect(mapped[0].attachedDb).toEqual({ id: 'ds_1', db_name: 'sales-db', db_type: '' })
+ expect(mapped[0].attachedDatabases).toEqual([
+ { id: 'ds_1', db_name: 'sales-db', db_type: '' },
+ { id: 'ds_2', db_name: 'ops-mongo', db_type: '' },
+ ])
+ })
+})
+
+describe('collectTurnInputFiles', () => {
+ it('keeps earlier attached files visible when the latest turn has none', () => {
+ const messages: ChatMessage[] = [
+ {
+ id: 'h1',
+ role: 'human',
+ context: 'این فایل',
+ order: 1,
+ attachedFiles: [
+ {
+ file_id: 'file_book1',
+ name: 'Book1 (1)',
+ size: 12,
+ media_type: 'application/xlsx',
+ kind: 'excel',
+ status: 'ready',
+ ordinal: 0,
+ },
+ ],
+ },
+ { id: 'v1', role: 'view', context: 'ok', order: 1 },
+ { id: 'h2', role: 'human', context: 'ده مشتری برتر را نشان بده', order: 2 },
+ ]
+
+ const files = collectTurnInputFiles(messages, [])
+ expect(files).toHaveLength(1)
+ expect(files[0]?.file_id).toBe('file_book1')
+ })
+
+ it('includes composer drafts so a library attach appears before send', () => {
+ const files = collectTurnInputFiles([], [
+ {
+ file_id: 'file_new',
+ name: 'sales.xlsx',
+ size: 8,
+ media_type: 'application/xlsx',
+ kind: 'excel',
+ status: 'ready',
+ ordinal: 0,
+ },
+ ])
+ expect(files[0]?.name).toBe('sales.xlsx')
+ })
+})
+
+describe('collectPinnedResources', () => {
+ it('keeps files from earlier turns as conversation pins', () => {
+ const messages: ChatMessage[] = [
+ {
+ id: 'h1',
+ role: 'human',
+ context: 'این فایل',
+ order: 1,
+ attachedFiles: [
+ {
+ file_id: 'file_book1',
+ name: 'Book1 (1)',
+ size: 12,
+ media_type: 'application/xlsx',
+ kind: 'excel',
+ status: 'ready',
+ ordinal: 0,
+ },
+ ],
+ attachedDb: { db_name: 'MongoDB', db_type: 'mongodb' },
+ },
+ { id: 'v1', role: 'view', context: 'ok', order: 1 },
+ { id: 'h2', role: 'human', context: 'فروش را بر اساس شعبه تفکیک کن', order: 2 },
+ ]
+
+ const pins = collectPinnedResources(messages)
+ expect(pins.files.map((file) => file.file_id)).toEqual(['file_book1'])
+ expect(pins.database).toEqual({ db_name: 'MongoDB', db_type: 'mongodb' })
+ expect(pins.databases).toEqual([{ db_name: 'MongoDB', db_type: 'mongodb' }])
+ })
+
+ it('unions files attached on different turns', () => {
+ const messages: ChatMessage[] = [
+ {
+ id: 'h1',
+ role: 'human',
+ context: 'فایل اول',
+ order: 1,
+ attachedFiles: [
+ {
+ file_id: 'file_a',
+ name: 'a.xlsx',
+ size: 1,
+ media_type: 'application/xlsx',
+ kind: 'excel',
+ status: 'ready',
+ ordinal: 0,
+ },
+ ],
+ },
+ {
+ id: 'h2',
+ role: 'human',
+ context: 'فایل دوم',
+ order: 2,
+ attachedFiles: [
+ {
+ file_id: 'file_b',
+ name: 'b.xlsx',
+ size: 1,
+ media_type: 'application/xlsx',
+ kind: 'excel',
+ status: 'ready',
+ ordinal: 0,
+ },
+ ],
+ attachedKnowledge: { id: 'kb_1', name: 'اسناد' },
+ },
+ ]
+
+ const pins = collectPinnedResources(messages)
+ expect(pins.files.map((file) => file.file_id)).toEqual(['file_a', 'file_b'])
+ expect(pins.knowledge).toEqual({ id: 'kb_1', name: 'اسناد' })
+ })
+})
diff --git a/src/features/assistant/adapters/historyToMessages.ts b/src/features/assistant/adapters/historyToMessages.ts
new file mode 100644
index 0000000..4991dab
--- /dev/null
+++ b/src/features/assistant/adapters/historyToMessages.ts
@@ -0,0 +1,221 @@
+import { isLiveRunStatus } from '../../../contracts/common'
+import type { ChatMessage } from '../types/chat'
+import type { SessionFileSnapshot } from '../session-files/types'
+
+export type HistoryResourceRef = {
+ id?: string
+ resource_id?: string
+ type?: string
+ resource_type?: string
+ name?: string
+ resource_name?: string
+}
+
+export type HistoryMessage = {
+ id: string
+ role: string
+ content: string
+ run_id?: string | null
+ resource_refs?: HistoryResourceRef[]
+}
+
+export type HistoryLatestRun = {
+ id: string
+ status: string
+ answer?: string
+ resources?: HistoryResourceRef[]
+}
+
+function refType(ref: HistoryResourceRef): string {
+ return String(ref.type || ref.resource_type || 'file')
+}
+
+function refId(ref: HistoryResourceRef): string {
+ return String(ref.id || ref.resource_id || '')
+}
+
+function refName(ref: HistoryResourceRef): string {
+ return String(ref.name || ref.resource_name || '')
+}
+
+export function refsToFiles(refs: HistoryResourceRef[] | undefined): SessionFileSnapshot[] {
+ return (refs ?? [])
+ .filter((ref) => refType(ref) === 'file')
+ .map((ref, index) => ({
+ file_id: refId(ref),
+ name: refName(ref) || 'file',
+ size: 0,
+ media_type: 'application/octet-stream',
+ kind: 'file',
+ status: 'ready' as const,
+ ordinal: index,
+ }))
+ .filter((file) => file.file_id)
+}
+
+export function refsToKnowledge(
+ refs: HistoryResourceRef[] | undefined,
+): ChatMessage['attachedKnowledge'] | undefined {
+ const hit = (refs ?? []).find((ref) => refType(ref) === 'knowledge')
+ if (!hit) return undefined
+ const id = refId(hit)
+ const name = refName(hit) || id
+ if (!id && !name) return undefined
+ return { id, name }
+}
+
+export function refsToDatabases(
+ refs: HistoryResourceRef[] | undefined,
+): NonNullable {
+ return (refs ?? [])
+ .filter((ref) => refType(ref) === 'datasource')
+ .map((ref) => {
+ const id = refId(ref)
+ const name = refName(ref) || id
+ return { id: id || undefined, db_name: name, db_type: '' }
+ })
+ .filter((item) => item.db_name)
+}
+
+export function refsToDatabase(
+ refs: HistoryResourceRef[] | undefined,
+): ChatMessage['attachedDb'] | undefined {
+ return refsToDatabases(refs)[0]
+}
+
+export function humanForView(
+ messages: readonly ChatMessage[],
+ viewId?: string | null,
+): ChatMessage | undefined {
+ if (viewId) {
+ const viewIndex = messages.findIndex((msg) => msg.id === viewId)
+ if (viewIndex > 0) {
+ for (let index = viewIndex - 1; index >= 0; index -= 1) {
+ if (messages[index]?.role === 'human') return messages[index]
+ }
+ }
+ }
+ return [...messages].reverse().find((msg) => msg.role === 'human')
+}
+
+export function collectPinnedResources(messages: readonly ChatMessage[]): {
+ files: SessionFileSnapshot[]
+ database?: ChatMessage['attachedDb']
+ databases: NonNullable
+ knowledge?: ChatMessage['attachedKnowledge']
+} {
+ const files = collectTurnInputFiles(messages, [])
+ const databases: Array<{ id?: string; db_name: string; db_type: string }> = []
+ const seen = new Set()
+ for (const msg of messages) {
+ if (msg.role !== 'human') continue
+ const items = msg.attachedDatabases?.length
+ ? msg.attachedDatabases
+ : msg.attachedDb?.db_name
+ ? [msg.attachedDb]
+ : []
+ for (const item of items) {
+ const key = ('id' in item && item.id) || item.db_name
+ if (!key || seen.has(key)) continue
+ seen.add(key)
+ databases.push({
+ id: 'id' in item ? item.id : undefined,
+ db_name: item.db_name,
+ db_type: item.db_type,
+ })
+ }
+ }
+ const lastHumanWithKnowledge = [...messages]
+ .reverse()
+ .find((msg) => msg.role === 'human' && msg.attachedKnowledge?.name)
+ return {
+ files,
+ database: databases[databases.length - 1],
+ databases,
+ knowledge: lastHumanWithKnowledge?.attachedKnowledge,
+ }
+}
+
+export function collectTurnInputFiles(
+ messages: readonly ChatMessage[],
+ draftSnapshots: readonly (SessionFileSnapshot | null | undefined)[],
+): SessionFileSnapshot[] {
+ const map = new Map()
+ for (const msg of messages) {
+ if (msg.role !== 'human') continue
+ for (const file of msg.attachedFiles ?? []) {
+ if (file?.file_id) map.set(file.file_id, file)
+ }
+ }
+ for (const file of draftSnapshots) {
+ if (file?.file_id) map.set(file.file_id, file)
+ }
+ return [...map.values()]
+}
+
+export function historyToMessages(
+ messages: HistoryMessage[],
+ latestRun?: HistoryLatestRun | null,
+): ChatMessage[] {
+ const mapped: ChatMessage[] = []
+ let order = 0
+ for (const msg of messages) {
+ const role = String(msg.role || '')
+ if (role === 'user' || role === 'human') {
+ order += 1
+ mapped.push({
+ id: msg.id,
+ role: 'human',
+ context: msg.content || '',
+ order,
+ attachedFiles: refsToFiles(msg.resource_refs),
+ attachedKnowledge: refsToKnowledge(msg.resource_refs),
+ attachedDatabases: refsToDatabases(msg.resource_refs),
+ attachedDb: refsToDatabase(msg.resource_refs),
+ })
+ } else if (role === 'assistant' || role === 'view') {
+ mapped.push({
+ id: msg.id,
+ role: 'view',
+ context: msg.content || '',
+ order,
+ thinking: false,
+ runId: msg.run_id || undefined,
+ })
+ }
+ }
+
+ const lastHuman = [...mapped].reverse().find((msg) => msg.role === 'human')
+ if (lastHuman && latestRun?.resources?.length) {
+ if (!lastHuman.attachedFiles?.length) {
+ lastHuman.attachedFiles = refsToFiles(latestRun.resources)
+ }
+ if (!lastHuman.attachedKnowledge) {
+ lastHuman.attachedKnowledge = refsToKnowledge(latestRun.resources)
+ }
+ if (!lastHuman.attachedDatabases?.length) {
+ lastHuman.attachedDatabases = refsToDatabases(latestRun.resources)
+ }
+ if (!lastHuman.attachedDb) {
+ lastHuman.attachedDb = refsToDatabase(latestRun.resources)
+ }
+ }
+
+ const last = mapped[mapped.length - 1]
+ const live = Boolean(latestRun && isLiveRunStatus(latestRun.status))
+ if (live && last?.role === 'human') {
+ mapped.push({
+ id: `view-${latestRun!.id}`,
+ role: 'view',
+ context: '',
+ order: last.order,
+ thinking: true,
+ runId: latestRun!.id,
+ })
+ } else if (live && last?.role === 'view' && !last.context.trim()) {
+ last.thinking = true
+ if (!last.runId) last.runId = latestRun?.id
+ }
+
+ return mapped
+}
diff --git a/src/features/assistant/adapters/runToTurn.ts b/src/features/assistant/adapters/runToTurn.ts
new file mode 100644
index 0000000..97b1188
--- /dev/null
+++ b/src/features/assistant/adapters/runToTurn.ts
@@ -0,0 +1,30 @@
+import type { ChatMessage } from '../types/chat'
+import type { AgentTurnView } from '../types/viewModels'
+
+export function messagesToTurns(
+ messages: ChatMessage[],
+): Array<{ human: ChatMessage; view?: ChatMessage }> {
+ const result: Array<{ human: ChatMessage; view?: ChatMessage }> = []
+ for (let i = 0; i < messages.length; i += 1) {
+ const msg = messages[i]
+ if (msg.role === 'human') {
+ result.push({
+ human: msg,
+ view: messages[i + 1]?.role === 'view' ? messages[i + 1] : undefined,
+ })
+ }
+ }
+ return result
+}
+
+export function toAgentTurnView(round: { human: ChatMessage; view?: ChatMessage }): AgentTurnView {
+ return {
+ id: round.human.id,
+ userMessage: round.human.context,
+ assistantMessage: round.view?.context,
+ taskPlan: round.view?.taskPlan,
+ attachedFiles: round.human.attachedFiles,
+ isWorking: Boolean(round.view?.thinking),
+ thinkingContent: round.view?.thinking ? '' : undefined,
+ }
+}
diff --git a/src/features/assistant/adapters/stepLabels.test.ts b/src/features/assistant/adapters/stepLabels.test.ts
new file mode 100644
index 0000000..3e15ed1
--- /dev/null
+++ b/src/features/assistant/adapters/stepLabels.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { stepDetailLabel, stepTypeLabel, toolActionName } from './stepLabels'
+
+describe('stepTypeLabel', () => {
+ it('translates a tool slug used as the step title', () => {
+ expect(stepTypeLabel('code_interpreter', 'code_interpreter')).toBe('تحلیل داده')
+ })
+
+ it('translates a known action when the title is empty', () => {
+ expect(stepTypeLabel('load_skill', '')).toBe('بارگذاری مهارت')
+ })
+
+ it('keeps a free-form title that is not a tool slug', () => {
+ expect(stepTypeLabel('code_interpreter', 'جمعبندی فروش')).toBe('جمعبندی فروش')
+ })
+})
+
+describe('toolActionName', () => {
+ it('hides the raw slug when a Persian label exists', () => {
+ expect(toolActionName('code_interpreter')).toBe('')
+ })
+
+ it('keeps unknown action names', () => {
+ expect(toolActionName('custom_tool')).toBe('custom_tool')
+ })
+})
+
+describe('stepDetailLabel', () => {
+ it('translates the ReAct placeholder', () => {
+ expect(stepDetailLabel('Thought/Action/Observation')).toBe('تفکر / اقدام / مشاهده')
+ })
+})
diff --git a/src/features/assistant/adapters/stepLabels.ts b/src/features/assistant/adapters/stepLabels.ts
new file mode 100644
index 0000000..f970fc4
--- /dev/null
+++ b/src/features/assistant/adapters/stepLabels.ts
@@ -0,0 +1,71 @@
+import { t } from '../../../i18n'
+
+const ENGINE_LABELS: Record = {
+ code_interpreter: 'assistant.execution.dataAnalysis',
+ execute_analysis: 'assistant.execution.dataAnalysis',
+ sql_executor: 'assistant.execution.databaseQuery',
+ sql_query: 'assistant.execution.databaseQuery',
+ knowledge_search: 'assistant.execution.knowledgeSearch',
+ load_file: 'assistant.execution.fileInspection',
+ file_reader: 'assistant.execution.fileInspection',
+ load_skill: 'assistant.execution.loadSkill',
+ execute_skill_script_file: 'assistant.execution.executeSkill',
+ html_interpreter: 'assistant.execution.htmlBuild',
+ shell_interpreter: 'assistant.execution.shellCommand',
+ question: 'assistant.execution.askUser',
+}
+
+const CATEGORY_LABELS: Record = {
+ code_interpreter: 'assistant.panel.categoryPython',
+ execute_analysis: 'assistant.panel.categoryPython',
+ execute_skill_script_file: 'assistant.panel.categoryPython',
+ sql_executor: 'assistant.panel.categorySql',
+ sql_query: 'assistant.panel.categorySql',
+ load_file: 'assistant.panel.categoryFile',
+ file_reader: 'assistant.panel.categoryFile',
+ knowledge_search: 'assistant.panel.categoryKnowledge',
+ html_interpreter: 'assistant.panel.categoryHtml',
+ shell_interpreter: 'assistant.panel.categoryShell',
+ load_skill: 'assistant.panel.categorySkill',
+}
+
+const DETAIL_LABELS: Record = {
+ 'Thought/Action/Observation': 'assistant.execution.reactDetail',
+}
+
+function engineLabelKey(value?: string): string | undefined {
+ const key = value?.trim()
+ return key ? ENGINE_LABELS[key] : undefined
+}
+
+export function stepTypeLabel(action?: string, title?: string): string {
+ const actionKey = action?.trim() || ''
+ const titleKey = title?.trim() || ''
+ const mappedTitle = engineLabelKey(titleKey)
+ if (mappedTitle) return t(mappedTitle)
+ if (titleKey && titleKey !== actionKey) return titleKey
+ const mappedAction = engineLabelKey(actionKey)
+ if (mappedAction) return t(mappedAction)
+ if (titleKey) return titleKey
+ if (actionKey) return actionKey
+ return t('assistant.execution.processing')
+}
+
+export function stepCategoryLabel(action?: string): string {
+ if (!action) return t('assistant.panel.categoryTool')
+ const key = CATEGORY_LABELS[action]
+ return key ? t(key) : t('assistant.panel.categoryTool')
+}
+
+export function toolActionName(action?: string): string {
+ const name = action?.trim() || ''
+ if (!name || engineLabelKey(name)) return ''
+ return name
+}
+
+export function stepDetailLabel(detail?: string): string {
+ const text = detail?.trim() || ''
+ if (!text) return ''
+ const key = DETAIL_LABELS[text]
+ return key ? t(key) : text
+}
diff --git a/src/features/assistant/api/chat.api.ts b/src/features/assistant/api/chat.api.ts
new file mode 100644
index 0000000..b5da6e4
--- /dev/null
+++ b/src/features/assistant/api/chat.api.ts
@@ -0,0 +1,77 @@
+import { kamidsoUrl } from '../../../services/api'
+
+export type ResourceRef = {
+ id: string
+ type: 'file' | 'datasource' | 'knowledge'
+ name?: string
+}
+
+export type KamidsoChatRequest = {
+ conversation_id?: string
+ message: string
+ resources?: ResourceRef[]
+ skill_id?: string
+ model_id?: string
+ mode?: 'auto' | 'analysis'
+ parent_run_id?: string
+ temperature?: number
+ max_new_tokens?: number
+ reasoning_mode?: 'fast' | 'medium' | 'deep'
+ report_mode?: boolean
+}
+
+export async function streamChat(
+ body: KamidsoChatRequest,
+ signal: AbortSignal,
+): Promise {
+ const response = await fetch(kamidsoUrl('/chat'), {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
+ body: JSON.stringify(body),
+ signal,
+ })
+ if (!response.ok) {
+ const data = await response.json().catch(() => null)
+ throw new Error((data as { message?: string })?.message || 'ارسال پیام ناموفق بود')
+ }
+ if (!response.body) throw new Error('No response body')
+ return response
+}
+
+export async function replyQuestion(runId: string, questionId: string, reply: string) {
+ if (!questionId) {
+ throw new Error('شناسه سؤال دریافت نشد')
+ }
+ const response = await fetch(kamidsoUrl(`/runs/${runId}/questions/${encodeURIComponent(questionId)}/reply`), {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ reply }),
+ })
+ if (!response.ok) {
+ const data = await response.json().catch(() => null)
+ throw new Error((data as { message?: string; detail?: { message?: string } })?.detail?.message || 'ارسال پاسخ ناموفق بود')
+ }
+}
+
+export async function rejectQuestion(runId: string, questionId: string) {
+ if (!questionId) {
+ throw new Error('شناسه سؤال دریافت نشد')
+ }
+ const response = await fetch(kamidsoUrl(`/runs/${runId}/questions/${encodeURIComponent(questionId)}/reject`), {
+ method: 'POST',
+ credentials: 'include',
+ })
+ if (!response.ok) {
+ const data = await response.json().catch(() => null)
+ throw new Error((data as { detail?: { message?: string } })?.detail?.message || 'رد سؤال ناموفق بود')
+ }
+}
+
+export async function cancelRun(runId: string) {
+ await fetch(kamidsoUrl(`/runs/${runId}/cancel`), {
+ method: 'POST',
+ credentials: 'include',
+ })
+}
diff --git a/src/features/assistant/api/conversations.api.ts b/src/features/assistant/api/conversations.api.ts
new file mode 100644
index 0000000..b047292
--- /dev/null
+++ b/src/features/assistant/api/conversations.api.ts
@@ -0,0 +1,101 @@
+import { apiFetch } from '../../../services/api'
+
+export type ConversationListItem = {
+ id: string
+ conversation_id: string
+ title?: string
+ updated_at?: string
+}
+
+export type ConversationListPage = {
+ items: ConversationListItem[]
+ total: number
+ limit: number
+ offset: number
+}
+
+export async function listConversations(params?: {
+ limit?: number
+ offset?: number
+}): Promise {
+ const query = new URLSearchParams()
+ if (params?.limit != null) query.set('limit', String(params.limit))
+ if (params?.offset != null) query.set('offset', String(params.offset))
+ const suffix = query.toString() ? `?${query}` : ''
+ const raw = await apiFetch<{
+ conversations?: ConversationListItem[]
+ total?: number
+ limit?: number
+ offset?: number
+ }>(`/conversations${suffix}`)
+ const items = raw.conversations ?? []
+ return {
+ items,
+ total: raw.total ?? items.length,
+ limit: raw.limit ?? params?.limit ?? items.length,
+ offset: raw.offset ?? params?.offset ?? 0,
+ }
+}
+
+export async function createConversation(title?: string): Promise {
+ const raw = await apiFetch<{ conversation_id: string }>('/conversations', {
+ method: 'POST',
+ body: JSON.stringify({ title }),
+ })
+ return raw.conversation_id
+}
+
+/** Resolve a server-backed conversation id (never a client-only UUID). */
+export async function ensureConversation(existingId?: string | null): Promise {
+ if (existingId) return existingId
+ return createConversation()
+}
+
+export type ConversationHistoryMessage = {
+ id: string
+ role: string
+ content: string
+ run_id?: string | null
+ resource_refs?: Array<{
+ id?: string
+ resource_id?: string
+ type?: string
+ resource_type?: string
+ name?: string
+ resource_name?: string
+ }>
+}
+
+export type ConversationHistoryResponse = {
+ conversation: { id: string; title?: string }
+ messages: ConversationHistoryMessage[]
+ active_run_id?: string | null
+ latest_run?: {
+ id: string
+ status: string
+ answer?: string
+ steps?: Array<{
+ id: string
+ title: string
+ status: string
+ type?: string
+ }>
+ artifacts?: unknown[]
+ resources?: Array<{
+ id?: string
+ resource_id?: string
+ type?: string
+ resource_type?: string
+ name?: string
+ resource_name?: string
+ }>
+ } | null
+}
+
+export async function getConversationHistory(
+ conversationId: string,
+): Promise {
+ return apiFetch(
+ `/conversations/${encodeURIComponent(conversationId)}`,
+ )
+}
diff --git a/src/features/assistant/api/reports.api.ts b/src/features/assistant/api/reports.api.ts
new file mode 100644
index 0000000..8e22230
--- /dev/null
+++ b/src/features/assistant/api/reports.api.ts
@@ -0,0 +1,32 @@
+import { apiFetch } from '../../../services/api'
+
+export type HtmlShare = {
+ id: string
+ title: string
+ share_path: string
+}
+
+export type SharedHtmlReport = {
+ id: string
+ title: string
+ html: string
+}
+
+export async function createHtmlShare(html: string, title?: string): Promise {
+ const raw = await apiFetch<{ report: HtmlShare }>('/reports/html-shares', {
+ method: 'POST',
+ body: JSON.stringify({ html, title }),
+ })
+ return raw.report
+}
+
+export async function fetchSharedReport(reportId: string): Promise {
+ const raw = await apiFetch<{
+ report: { id: string; title?: string; html?: string; kind?: string }
+ }>(`/share/reports/${encodeURIComponent(reportId)}`)
+ return {
+ id: String(raw.report?.id ?? reportId),
+ title: String(raw.report?.title ?? ''),
+ html: String(raw.report?.html ?? ''),
+ }
+}
diff --git a/src/features/assistant/api/resources.api.ts b/src/features/assistant/api/resources.api.ts
new file mode 100644
index 0000000..4660b69
--- /dev/null
+++ b/src/features/assistant/api/resources.api.ts
@@ -0,0 +1,65 @@
+import { apiFetchEnvelope } from '../../../services/api'
+import type { ResourceOption } from '../types/chat'
+import { listKnowledgeSpaces as listKnowledgeSpaceRecords } from '../../knowledge/api'
+import { listDatasourceRecords, toResourceOption } from '../../databases/api'
+
+export async function listModels(): Promise {
+ try {
+ const data = await apiFetchEnvelope<{ [key: string]: unknown }>('/models')
+ if (Array.isArray(data)) return data as string[]
+ const models = (data as { models?: string[] })?.models
+ if (Array.isArray(models)) return models
+ return []
+ } catch {
+ return []
+ }
+}
+
+export async function listDatasources(): Promise {
+ try {
+ const rows = await listDatasourceRecords()
+ return rows.map(toResourceOption)
+ } catch {
+ return []
+ }
+}
+
+export async function listKnowledgeSpaces(): Promise {
+ try {
+ const spaces = await listKnowledgeSpaceRecords()
+ return spaces.map((space) => ({
+ id: space.id,
+ name: space.name,
+ description: space.description || '',
+ }))
+ } catch {
+ return []
+ }
+}
+
+export async function listSkills(): Promise {
+ try {
+ const data = await apiFetchEnvelope('/skills')
+ const list = Array.isArray(data)
+ ? data
+ : (data as { data?: unknown[]; skills?: unknown[] })?.skills ??
+ (data as { data?: unknown[] })?.data
+ if (!Array.isArray(list)) return []
+ return list.map((item, i) => {
+ const row = item as Record
+ return {
+ id: String(row.id ?? row.name ?? i),
+ name: String(row.name ?? `skill-${i}`),
+ description: String(row.description ?? ''),
+ nameEn: row.name_en ? String(row.name_en) : undefined,
+ category: row.category ? String(row.category) : undefined,
+ categoryId: row.category_id ? String(row.category_id) : undefined,
+ domain: row.domain ? String(row.domain) : undefined,
+ icon: row.icon ? String(row.icon) : undefined,
+ settings: row.settings && typeof row.settings === 'object' ? (row.settings as ResourceOption['settings']) : undefined,
+ }
+ })
+ } catch {
+ return []
+ }
+}
diff --git a/src/features/assistant/api/runs.api.ts b/src/features/assistant/api/runs.api.ts
new file mode 100644
index 0000000..09fb3ae
--- /dev/null
+++ b/src/features/assistant/api/runs.api.ts
@@ -0,0 +1,23 @@
+import { apiFetch, kamidsoUrl } from '../../../api/client'
+import type { KamidsoRun } from '../../../contracts/run'
+
+export async function getRun(runId: string): Promise {
+ return apiFetch(`/runs/${runId}`)
+}
+
+export async function streamRunEvents(
+ runId: string,
+ after: number,
+ signal?: AbortSignal,
+): Promise {
+ const response = await fetch(kamidsoUrl(`/runs/${runId}/events?after=${after}`), {
+ credentials: 'include',
+ headers: { Accept: 'text/event-stream' },
+ signal,
+ })
+ if (!response.ok) {
+ throw new Error('دریافت رویدادهای اجرا ناموفق بود.')
+ }
+ if (!response.body) throw new Error('No response body')
+ return response
+}
diff --git a/src/features/assistant/artifacts/ArtifactRenderer.tsx b/src/features/assistant/artifacts/ArtifactRenderer.tsx
new file mode 100644
index 0000000..2eff7ec
--- /dev/null
+++ b/src/features/assistant/artifacts/ArtifactRenderer.tsx
@@ -0,0 +1,26 @@
+import type { KamidsoArtifact } from '../../../contracts/run'
+import { CodePreview } from './CodePreview'
+import { TablePreview } from './TablePreview'
+import { ChartPreview } from './ChartPreview'
+import { JsonPreview } from './JsonPreview'
+import { HtmlPreview } from '../components/HtmlPreview'
+import { MarkdownAnswer } from '../components/MarkdownAnswer'
+
+export function ArtifactRenderer({ artifact }: { artifact: KamidsoArtifact }) {
+ switch (artifact.type) {
+ case 'code':
+ return
+ case 'table':
+ return
+ case 'chart':
+ return } />
+ case 'html':
+ return
+ case 'markdown':
+ return
+ case 'json':
+ return
+ default:
+ return
+ }
+}
diff --git a/src/features/assistant/artifacts/ChartPreview.tsx b/src/features/assistant/artifacts/ChartPreview.tsx
new file mode 100644
index 0000000..64a8959
--- /dev/null
+++ b/src/features/assistant/artifacts/ChartPreview.tsx
@@ -0,0 +1,246 @@
+import { lazy, Suspense } from 'react'
+import { formatNumber, parseNumeric } from '../report/formatValue'
+
+type SeriesItem = { field: string; label: string }
+
+function normalizeSpec(spec: Record): {
+ chartType: string
+ data: Record[]
+ xKey: string
+ series: SeriesItem[]
+ stacked: boolean
+ showGrid: boolean
+ height?: number
+} {
+ const data = (spec.data as Record[]) || []
+ const xKey = String(spec.x ?? spec.xField ?? 'name')
+ const chartType = String(spec.chartType ?? 'bar')
+ const stacked = spec.stacked === true
+ const showGrid = spec.showGrid !== false
+ const height = typeof spec.height === 'number' ? spec.height : undefined
+
+ let series: SeriesItem[] = []
+ const rawSeries = spec.series
+ if (Array.isArray(rawSeries) && rawSeries.length) {
+ series = rawSeries
+ .map((entry) => {
+ if (typeof entry === 'string') return { field: entry, label: entry }
+ if (!entry || typeof entry !== 'object') return null
+ const obj = entry as Record
+ const field = String(obj.field ?? obj.key ?? '').trim()
+ if (!field) return null
+ return { field, label: String(obj.label ?? field) }
+ })
+ .filter((x): x is SeriesItem => Boolean(x))
+ } else {
+ const yRaw = spec.y ?? spec.yField ?? 'value'
+ if (Array.isArray(yRaw)) {
+ series = yRaw.map((field) => ({ field: String(field), label: String(field) }))
+ } else {
+ series = [{ field: String(yRaw), label: String(yRaw) }]
+ }
+ }
+ if (!series.length) series = [{ field: 'value', label: 'value' }]
+ return { chartType, data, xKey, series, stacked, showGrid, height }
+}
+
+type ChartPreviewProps = {
+ spec: Record
+ variant?: 'code' | 'light'
+ compact?: boolean
+}
+
+const COLORS = ['#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2']
+
+const ChartInner = lazy(async () => {
+ try {
+ const recharts = await import('recharts')
+ const {
+ ResponsiveContainer,
+ BarChart,
+ Bar,
+ LineChart,
+ Line,
+ AreaChart,
+ Area,
+ PieChart,
+ Pie,
+ ScatterChart,
+ Scatter,
+ Cell,
+ XAxis,
+ YAxis,
+ Tooltip,
+ CartesianGrid,
+ Legend,
+ ZAxis,
+ } = recharts
+ return {
+ default: function ChartInner({ spec, variant = 'code', compact = false }: ChartPreviewProps) {
+ const { chartType, data, xKey, series, stacked, showGrid, height: customHeight } =
+ normalizeSpec(spec)
+ if (!data.length) {
+ return (
+
+ برای این بازه زمانی دادهای موجود نیست.
+
+ )
+ }
+ const height = customHeight ?? (compact ? 180 : 280)
+ const light = variant === 'light'
+ const grid = light ? '#e5eaf2' : '#334155'
+ const tick = { fill: light ? '#64748b' : '#cbd5e1', fontSize: 11 }
+ const showLegend = series.length > 1 || spec.legend === true
+ const tooltip = {
+ contentStyle: light
+ ? {
+ borderRadius: 12,
+ border: '1px solid #e5eaf2',
+ background: '#fff',
+ fontSize: 12,
+ }
+ : undefined,
+ formatter: (value: unknown, name: unknown) => {
+ const numeric = parseNumeric(value)
+ const formatted = numeric == null ? String(value ?? '') : formatNumber(numeric)
+ return [formatted, String(name ?? '')]
+ },
+ }
+
+ if (chartType === 'pie' || chartType === 'donut') {
+ const yKey = series[0].field
+ const inner = chartType === 'donut' ? (compact ? 36 : 48) : 0
+ return (
+
+
+
+ {data.map((_, index) => (
+ |
+ ))}
+
+
+ {showLegend ? : null}
+
+
+ )
+ }
+
+ if (chartType === 'scatter') {
+ const xField = series[0]?.field || xKey
+ const yField = series[1]?.field || series[0]?.field || 'value'
+ return (
+
+
+ {showGrid ? : null}
+
+
+
+
+
+
+
+ )
+ }
+
+ if (chartType === 'area') {
+ return (
+
+
+ {showGrid ? : null}
+
+
+
+ {showLegend ? : null}
+ {series.map((s, index) => (
+
+ ))}
+
+
+ )
+ }
+
+ if (chartType === 'line') {
+ return (
+
+
+ {showGrid ? : null}
+
+
+
+ {showLegend ? : null}
+ {series.map((s, index) => (
+
+ ))}
+
+
+ )
+ }
+
+ return (
+
+
+ {showGrid ? : null}
+
+
+
+ {showLegend ? : null}
+ {series.map((s, index) => (
+
+ ))}
+
+
+ )
+ },
+ }
+ } catch {
+ return {
+ default: function Fallback({ spec }: ChartPreviewProps) {
+ return (
+
+ {JSON.stringify(spec, null, 2)}
+
+ )
+ },
+ }
+ }
+})
+
+export function ChartPreview({ spec, variant = 'code', compact = false }: ChartPreviewProps) {
+ return (
+ بارگذاری نمودار…}>
+
+
+ )
+}
diff --git a/src/features/assistant/artifacts/CodePreview.tsx b/src/features/assistant/artifacts/CodePreview.tsx
new file mode 100644
index 0000000..99adbf9
--- /dev/null
+++ b/src/features/assistant/artifacts/CodePreview.tsx
@@ -0,0 +1 @@
+export { CodeBlock as CodePreview } from '../components/CodeBlock'
diff --git a/src/features/assistant/artifacts/FileDownloadCard.tsx b/src/features/assistant/artifacts/FileDownloadCard.tsx
new file mode 100644
index 0000000..25f4851
--- /dev/null
+++ b/src/features/assistant/artifacts/FileDownloadCard.tsx
@@ -0,0 +1,57 @@
+import { Download, FileSpreadsheet } from 'lucide-react'
+import { t } from '../../../i18n'
+import { resolveApiUrl } from '../../../services/apiBase'
+
+export type DownloadableFile = {
+ url: string
+ name: string
+}
+
+/** Normalize file chunk / artifact content into a downloadable link. */
+export function parseDownloadableFile(content: unknown): DownloadableFile | null {
+ if (content && typeof content === 'object') {
+ const obj = content as Record
+ const url = typeof obj.url === 'string' ? obj.url.trim() : ''
+ if (!url) return null
+ const name =
+ (typeof obj.name === 'string' && obj.name.trim()) ||
+ url.split('/').pop() ||
+ 'download'
+ return { url: resolveApiUrl(url), name: String(name) }
+ }
+ if (typeof content === 'string' && content.trim()) {
+ const url = content.trim()
+ if (!url.startsWith('/files/') && !url.startsWith('http')) return null
+ return { url: resolveApiUrl(url), name: url.split('/').pop() || 'download' }
+ }
+ return null
+}
+
+export function FileDownloadCard({
+ file,
+ compact,
+}: {
+ file: DownloadableFile
+ compact?: boolean
+}) {
+ return (
+
+
+
+
+
+
{file.name}
+
{t('assistant.panel.downloadFile')}
+
+
+
+ )
+}
diff --git a/src/features/assistant/artifacts/JsonPreview.tsx b/src/features/assistant/artifacts/JsonPreview.tsx
new file mode 100644
index 0000000..fdee19b
--- /dev/null
+++ b/src/features/assistant/artifacts/JsonPreview.tsx
@@ -0,0 +1,7 @@
+export function JsonPreview({ data }: { data: unknown }) {
+ return (
+
+ {typeof data === 'string' ? data : JSON.stringify(data, null, 2)}
+
+ )
+}
diff --git a/src/features/assistant/artifacts/TablePreview.tsx b/src/features/assistant/artifacts/TablePreview.tsx
new file mode 100644
index 0000000..7ffc1dd
--- /dev/null
+++ b/src/features/assistant/artifacts/TablePreview.tsx
@@ -0,0 +1,61 @@
+import { normalizeTableContent } from './normalizeTable'
+
+export function TablePreview({
+ table,
+}: {
+ table:
+ | { columns?: string[] | Array<{ title?: string; dataIndex?: string; key?: string }>; rows?: unknown[] }
+ | Record
+}) {
+ const normalized = normalizeTableContent(table)
+ if (!normalized) {
+ return (
+
+ {JSON.stringify(table, null, 2)}
+
+ )
+ }
+
+ const { columns, rows } = normalized
+
+ return (
+
+
+
+
+ {columns.map((col) => (
+ |
+ {col.title}
+ |
+ ))}
+
+
+
+ {rows.slice(0, 100).map((row, rowIndex) => (
+
+ {columns.map((col) => (
+ |
+ {formatCell(row[col.dataIndex])}
+ |
+ ))}
+
+ ))}
+
+
+ {rows.length > 100 ? (
+
+ {rows.length - 100}+ ردیف دیگر
+
+ ) : null}
+
+ )
+}
+
+function formatCell(value: unknown): string {
+ if (value == null || (typeof value === 'number' && Number.isNaN(value))) return '—'
+ if (typeof value === 'object') return JSON.stringify(value)
+ return String(value)
+}
diff --git a/src/features/assistant/artifacts/normalizeTable.test.ts b/src/features/assistant/artifacts/normalizeTable.test.ts
new file mode 100644
index 0000000..12b6aaa
--- /dev/null
+++ b/src/features/assistant/artifacts/normalizeTable.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { normalizeTableContent } from './normalizeTable'
+
+describe('normalizeTableContent', () => {
+ it('handles ant-design style columns and record rows', () => {
+ const table = normalizeTableContent({
+ columns: [{ title: 'Name', dataIndex: 'name', key: 'name' }],
+ rows: [{ name: 'Ali' }, { name: 'Sara' }],
+ })
+ expect(table?.columns[0].dataIndex).toBe('name')
+ expect(table?.rows[0].name).toBe('Ali')
+ })
+
+ it('handles string columns and array rows', () => {
+ const table = normalizeTableContent({
+ columns: ['a', 'b'],
+ rows: [['1', '2']],
+ })
+ expect(table?.rows[0].a).toBe('1')
+ expect(table?.rows[0].b).toBe('2')
+ })
+})
diff --git a/src/features/assistant/artifacts/normalizeTable.ts b/src/features/assistant/artifacts/normalizeTable.ts
new file mode 100644
index 0000000..affc32b
--- /dev/null
+++ b/src/features/assistant/artifacts/normalizeTable.ts
@@ -0,0 +1,51 @@
+export type NormalizedTableColumn = {
+ key: string
+ title: string
+ dataIndex: string
+}
+
+export type NormalizedTable = {
+ columns: NormalizedTableColumn[]
+ rows: Record[]
+}
+
+/** Normalize DBGPT table payloads (string columns or Ant Design-style objects). */
+export function normalizeTableContent(content: unknown): NormalizedTable | null {
+ if (!content || typeof content !== 'object') return null
+ const raw = content as { columns?: unknown[]; headers?: unknown[]; rows?: unknown[] }
+ const rawColumns = raw.columns ?? raw.headers ?? []
+ const rawRows = raw.rows ?? []
+ if (!rawColumns.length) return null
+
+ const columns: NormalizedTableColumn[] = rawColumns.map((col, index) => {
+ if (typeof col === 'string') {
+ return { key: col, title: col, dataIndex: col }
+ }
+ if (col && typeof col === 'object') {
+ const item = col as { title?: string; dataIndex?: string; key?: string }
+ const dataIndex = String(item.dataIndex ?? item.key ?? item.title ?? index)
+ return {
+ key: String(item.key ?? dataIndex),
+ title: String(item.title ?? dataIndex),
+ dataIndex,
+ }
+ }
+ return { key: String(index), title: String(col), dataIndex: String(index) }
+ })
+
+ const rows: Record[] = rawRows.map((row, rowIndex) => {
+ if (Array.isArray(row)) {
+ const record: Record = { __rowKey: rowIndex }
+ columns.forEach((col, colIndex) => {
+ record[col.dataIndex] = row[colIndex]
+ })
+ return record
+ }
+ if (row && typeof row === 'object') {
+ return { ...(row as Record), __rowKey: rowIndex }
+ }
+ return { __rowKey: rowIndex, value: row }
+ })
+
+ return { columns, rows }
+}
diff --git a/src/features/assistant/components/CodeBlock.tsx b/src/features/assistant/components/CodeBlock.tsx
new file mode 100644
index 0000000..ff04aff
--- /dev/null
+++ b/src/features/assistant/components/CodeBlock.tsx
@@ -0,0 +1,94 @@
+import { Check, Copy, X } from 'lucide-react'
+import { useState } from 'react'
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
+import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
+import { t } from '../../../i18n'
+
+export function CodeBlock({
+ code,
+ language = 'text',
+ variant = 'light',
+ maxHeight,
+ label,
+ terminal,
+}: {
+ code: string
+ language?: string
+ variant?: 'light' | 'execution'
+ maxHeight?: number
+ label?: string
+ terminal?: { content: string; success?: boolean }
+}) {
+ const [copied, setCopied] = useState(false)
+ const isExecution = variant === 'execution'
+
+ async function copy() {
+ await navigator.clipboard.writeText(code)
+ setCopied(true)
+ window.setTimeout(() => setCopied(false), 1500)
+ }
+
+ const style = isExecution ? oneDark : oneLight
+ const bg = isExecution ? '#0f172a' : '#fff'
+
+ return (
+
+
+
+ {label || language}
+
+
+
+
+ {code}
+
+ {terminal ? (
+ <>
+
+
+ {t('assistant.panel.terminal')}
+ {terminal.success === false ? (
+
+ ) : (
+
+ )}
+
+
+ {terminal.content}
+
+ >
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/components/Composer.tsx b/src/features/assistant/components/Composer.tsx
new file mode 100644
index 0000000..57503df
--- /dev/null
+++ b/src/features/assistant/components/Composer.tsx
@@ -0,0 +1,87 @@
+import { Loader2, Send, Square } from 'lucide-react'
+import { useRef, type ReactNode } from 'react'
+import { Button } from '../../../components/Button'
+import AttachmentRail, { AttachmentRailAddButton } from '../session-files/AttachmentRail'
+import type { DraftFile, LegacyServerFile } from '../session-files'
+import { DATA_FILE_ACCEPT } from '../session-files/fileKind'
+
+export function Composer({
+ query,
+ onQueryChange,
+ onSend,
+ onStop,
+ loading,
+ drafts,
+ legacyFiles,
+ onRemoveFile,
+ onRetryFile,
+ onAddFiles,
+ resourceBar,
+}: {
+ query: string
+ onQueryChange: (value: string) => void
+ onSend: () => void
+ onStop: () => void
+ loading: boolean
+ drafts: readonly DraftFile[]
+ legacyFiles?: readonly LegacyServerFile[]
+ onRemoveFile: (clientId: string) => void
+ onRetryFile: (clientId: string) => void
+ onAddFiles: (files: FileList | File[]) => void
+ resourceBar?: ReactNode
+}) {
+ const fileInputRef = useRef(null)
+
+ return (
+
+ )
+}
diff --git a/src/features/assistant/components/ConversationRail.tsx b/src/features/assistant/components/ConversationRail.tsx
new file mode 100644
index 0000000..4d94b14
--- /dev/null
+++ b/src/features/assistant/components/ConversationRail.tsx
@@ -0,0 +1,75 @@
+import { MessageSquarePlus, PanelLeftClose } from 'lucide-react'
+import type { ConversationListItem } from '../api/conversations.api'
+
+export function ConversationRail({
+ items,
+ activeId,
+ onSelect,
+ onNew,
+ collapsed,
+ onToggle,
+}: {
+ items: ConversationListItem[]
+ activeId?: string | null
+ onSelect: (id: string) => void
+ onNew: () => void
+ collapsed: boolean
+ onToggle: () => void
+}) {
+ if (collapsed) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/features/assistant/components/HtmlPreview.test.tsx b/src/features/assistant/components/HtmlPreview.test.tsx
new file mode 100644
index 0000000..903af49
--- /dev/null
+++ b/src/features/assistant/components/HtmlPreview.test.tsx
@@ -0,0 +1,65 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createHtmlShare } from '../api/reports.api'
+import { downloadReportPdf } from '../report/downloadReportPdf'
+import { HtmlPreview } from './HtmlPreview'
+
+vi.mock('../report/downloadReportPdf', async () => {
+ const actual = await vi.importActual(
+ '../report/downloadReportPdf',
+ )
+ return { ...actual, downloadReportPdf: vi.fn() }
+})
+
+vi.mock('../api/reports.api', () => ({
+ createHtmlShare: vi.fn(async () => ({
+ id: 'rpt_abc',
+ title: 'گزارش',
+ share_path: '/share/reports/rpt_abc',
+ })),
+}))
+
+const sample = "گزارش
"
+
+describe('HtmlPreview', () => {
+ beforeEach(() => {
+ vi.mocked(downloadReportPdf).mockClear()
+ vi.mocked(createHtmlShare).mockClear()
+ })
+
+ it('opens the report full screen and closes it', async () => {
+ const user = userEvent.setup()
+ render()
+
+ expect(screen.getByTitle('html-preview')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'تمامصفحه' }))
+ expect(screen.getByRole('dialog', { name: 'گزارش تحلیل' })).toBeInTheDocument()
+ expect(screen.getByTitle('html-preview-full')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'بستن' }))
+ expect(screen.queryByRole('dialog', { name: 'گزارش تحلیل' })).not.toBeInTheDocument()
+ })
+
+ it('downloads the report as a print-ready PDF', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByRole('button', { name: 'دانلود PDF' }))
+ expect(downloadReportPdf).toHaveBeenCalledWith(sample, 'گزارش تحلیل')
+ })
+
+ it('creates a public share link and copies it', async () => {
+ const user = userEvent.setup()
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ vi.spyOn(navigator.clipboard, 'writeText').mockImplementation(writeText)
+ render()
+
+ await user.click(screen.getByRole('button', { name: 'اشتراکگذاری' }))
+ expect(createHtmlShare).toHaveBeenCalledWith(sample, 'گزارش تحلیل')
+ expect(await screen.findByText(/\/share\/reports\/rpt_abc$/)).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'کپی لینک' }))
+ expect(writeText).toHaveBeenCalledWith(expect.stringMatching(/\/share\/reports\/rpt_abc$/))
+ expect(screen.getByRole('button', { name: 'لینک کپی شد' })).toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/components/HtmlPreview.tsx b/src/features/assistant/components/HtmlPreview.tsx
new file mode 100644
index 0000000..7abf13b
--- /dev/null
+++ b/src/features/assistant/components/HtmlPreview.tsx
@@ -0,0 +1,276 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import { Check, Copy, Download, ExternalLink, FileText, Maximize2, Share2, X } from 'lucide-react'
+import { t } from '../../../i18n'
+import { useBodyScrollLock, useFocusTrap } from '../../../components/ui/useFocusTrap'
+import { createHtmlShare } from '../api/reports.api'
+import { downloadReportPdf, reportTitleFromHtml } from '../report/downloadReportPdf'
+
+function ReportFrame({
+ html,
+ title,
+ className,
+}: {
+ html: string
+ title: string
+ className: string
+}) {
+ return
+}
+
+type ShareAnchor = 'inline' | 'full'
+
+type ShareBox = { top: number; left: number; width: number }
+
+const actionClass =
+ 'inline-flex h-8 items-center gap-1.5 rounded-lg px-2 text-[12px] font-medium text-ink-muted transition hover:bg-black/5 hover:text-ink'
+
+function shareBoxFor(button: HTMLElement): ShareBox {
+ const rect = button.getBoundingClientRect()
+ const width = Math.min(352, window.innerWidth - 16)
+ const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8))
+ return { top: rect.bottom + 8, left, width }
+}
+
+export function HtmlPreview({ html }: { html: string }) {
+ const [expanded, setExpanded] = useState(false)
+ const [shareAnchor, setShareAnchor] = useState(null)
+ const [shareBox, setShareBox] = useState(null)
+ const shareButtonRef = useRef(null)
+ const [shareUrl, setShareUrl] = useState(null)
+ const [sharing, setSharing] = useState(false)
+ const [shareError, setShareError] = useState(false)
+ const [copied, setCopied] = useState(false)
+ const dialogRef = useRef(null)
+ const htmlRef = useRef(html)
+ const shareCache = useRef<{ html: string; url: string } | null>(null)
+ htmlRef.current = html
+
+ const expandLabel = t('assistant.report.expand')
+ const closeLabel = t('assistant.report.close')
+ const title = t('assistant.report.title')
+ const downloadLabel = t('assistant.report.downloadPdf')
+ const shareLabel = t('assistant.report.share')
+ const documentTitle = reportTitleFromHtml(html, title)
+
+ useFocusTrap(dialogRef, expanded)
+ useBodyScrollLock(expanded)
+
+ useEffect(() => {
+ if (!expanded && !shareAnchor) return
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key !== 'Escape') return
+ if (shareAnchor) {
+ setShareAnchor(null)
+ return
+ }
+ setExpanded(false)
+ }
+ document.addEventListener('keydown', onKeyDown)
+ return () => document.removeEventListener('keydown', onKeyDown)
+ }, [expanded, shareAnchor])
+
+ useEffect(() => {
+ if (!shareAnchor) return
+ function onPointerDown(event: MouseEvent) {
+ const target = event.target
+ if (!(target instanceof Element)) return
+ if (target.closest('[data-report-share]')) return
+ setShareAnchor(null)
+ }
+ function onPlace() {
+ const button = shareButtonRef.current
+ if (button) setShareBox(shareBoxFor(button))
+ }
+ onPlace()
+ document.addEventListener('mousedown', onPointerDown)
+ window.addEventListener('resize', onPlace)
+ window.addEventListener('scroll', onPlace, true)
+ return () => {
+ document.removeEventListener('mousedown', onPointerDown)
+ window.removeEventListener('resize', onPlace)
+ window.removeEventListener('scroll', onPlace, true)
+ }
+ }, [shareAnchor])
+
+ async function openShare(anchor: ShareAnchor, button: HTMLElement) {
+ shareButtonRef.current = button
+ setShareBox(shareBoxFor(button))
+ setShareAnchor(anchor)
+ setCopied(false)
+ setShareError(false)
+ const cached = shareCache.current
+ if (cached?.html === html) {
+ setShareUrl(cached.url)
+ return
+ }
+ const requested = html
+ setSharing(true)
+ setShareUrl(null)
+ try {
+ const created = await createHtmlShare(requested, reportTitleFromHtml(requested, title))
+ if (htmlRef.current !== requested) return
+ const url = `${window.location.origin}${created.share_path}`
+ shareCache.current = { html: requested, url }
+ setShareUrl(url)
+ } catch {
+ if (htmlRef.current === requested) setShareError(true)
+ } finally {
+ setSharing(false)
+ }
+ }
+
+ async function copyLink() {
+ if (!shareUrl) return
+ try {
+ await navigator.clipboard.writeText(shareUrl)
+ setCopied(true)
+ } catch {
+ setCopied(false)
+ }
+ }
+
+ function download() {
+ downloadReportPdf(html, documentTitle)
+ }
+
+ function toolbar(anchor: ShareAnchor, extra?: ReactNode) {
+ const shareOpen = shareAnchor === anchor
+ return (
+
+
+
+ {extra}
+
+ )
+ }
+
+ const sharePanel =
+ shareAnchor && shareBox
+ ? createPortal(
+
+ {sharing ?
{t('assistant.report.sharing')}
: null}
+ {shareError ?
{t('assistant.report.shareFailed')}
: null}
+ {shareUrl ? (
+
+
+ {shareUrl}
+
+
+
+
+
+ {t('assistant.report.openPage')}
+
+
+
+ ) : null}
+
,
+ document.body,
+ )
+ : null
+
+ return (
+ <>
+ {sharePanel}
+
+
+
+
+
+
+ {documentTitle}
+
+ {toolbar(
+ 'inline',
+
,
+ )}
+
+
+
+ {expanded
+ ? createPortal(
+
+
+
+
{documentTitle}
+ {toolbar(
+ 'full',
+ ,
+ )}
+
+
+
+
,
+ document.body,
+ )
+ : null}
+ >
+ )
+}
diff --git a/src/features/assistant/components/ManusLeftPanel.tsx b/src/features/assistant/components/ManusLeftPanel.tsx
new file mode 100644
index 0000000..b2ac449
--- /dev/null
+++ b/src/features/assistant/components/ManusLeftPanel.tsx
@@ -0,0 +1,54 @@
+import AttachmentMessageCards from '../session-files/AttachmentMessageCards'
+import type { SessionFileSnapshot } from '../session-files'
+import type { ChatMessage, ExecutionStep } from '../types/chat'
+import { MarkdownAnswer } from './MarkdownAnswer'
+import { StepTimeline } from './StepTimeline'
+import { TaskPlanCard } from './TaskPlanCard'
+
+export function ManusLeftPanel({
+ rounds,
+ activeViewId,
+ steps,
+ activeStepId,
+ onStepClick,
+ onPreviewFile,
+}: {
+ rounds: Array<{ human: ChatMessage; view?: ChatMessage }>
+ activeViewId?: string | null
+ steps: ExecutionStep[]
+ activeStepId?: string | null
+ onStepClick?: (stepId: string) => void
+ onPreviewFile?: (file: SessionFileSnapshot) => void
+}) {
+ return (
+
+ {rounds.map(({ human, view }) => {
+ const isActive = view?.id === activeViewId
+ return (
+
+ شما
+ {human.context}
+ {human.attachedFiles?.length ? (
+
+ ) : null}
+
+ {view ? (
+
+
دستیار
+ {view.taskPlan?.length ?
: null}
+ {isActive && steps.length ? (
+
+ ) : null}
+ {view.thinking ? (
+
در حال پردازش…
+ ) : view.context ? (
+
+ ) : null}
+
+ ) : null}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/features/assistant/components/ManusRightPanel.tsx b/src/features/assistant/components/ManusRightPanel.tsx
new file mode 100644
index 0000000..708baa9
--- /dev/null
+++ b/src/features/assistant/components/ManusRightPanel.tsx
@@ -0,0 +1,123 @@
+import { useMemo, useState } from 'react'
+import type { ExecutionOutput, ExecutionStep } from '../types/chat'
+import { CodeBlock } from './CodeBlock'
+import { HtmlPreview } from './HtmlPreview'
+import { MarkdownAnswer } from './MarkdownAnswer'
+
+type Tab = 'execution' | 'summary'
+
+export function ManusRightPanel({
+ steps,
+ outputs,
+ activeStepId,
+ summary,
+ loading,
+}: {
+ steps: ExecutionStep[]
+ outputs: Record
+ activeStepId?: string | null
+ summary?: string
+ loading?: boolean
+}) {
+ const [tab, setTab] = useState('execution')
+ const stepId = activeStepId || steps[steps.length - 1]?.id
+ const stepOutputs = stepId ? outputs[stepId] || [] : []
+
+ const rendered = useMemo(() => {
+ return stepOutputs.map((output, index) => {
+ if (output.output_type === 'code') {
+ return
+ }
+ if (output.output_type === 'html') {
+ return
+ }
+ if (output.output_type === 'markdown' || output.output_type === 'text') {
+ return
+ }
+ if (output.output_type === 'table' && output.content && typeof output.content === 'object') {
+ const table = output.content as { columns?: string[]; rows?: unknown[][] }
+ const columns = table.columns || []
+ const rows = table.rows || []
+ return (
+
+
+
+
+ {columns.map((col) => (
+ |
+ {col}
+ |
+ ))}
+
+
+
+ {rows.slice(0, 50).map((row, ri) => (
+
+ {columns.map((_, ci) => (
+ |
+ {String(row[ci] ?? '')}
+ |
+ ))}
+
+ ))}
+
+
+
+ )
+ }
+ if (output.output_type === 'image') {
+ const src = String(output.content)
+ return (
+
+ )
+ }
+ if (output.output_type === 'error') {
+ return (
+
+ {String(output.content)}
+
+ )
+ }
+ return (
+
+ {typeof output.content === 'string' ? output.content : JSON.stringify(output.content, null, 2)}
+
+ )
+ })
+ }, [stepOutputs])
+
+ return (
+
+
+
+
+
+
+ {loading && tab === 'execution' && !rendered.length ? (
+
در حال اجرای ابزار…
+ ) : null}
+ {tab === 'execution' ? (
+ rendered.length ?
{rendered}
: (
+
خروجی مرحلهٔ فعال اینجا نمایش داده میشود.
+ )
+ ) : summary ? (
+
+ ) : (
+
پس از اتمام، پاسخ نهایی اینجا ظاهر میشود.
+ )}
+
+
+ )
+}
diff --git a/src/features/assistant/components/MarkdownAnswer.test.tsx b/src/features/assistant/components/MarkdownAnswer.test.tsx
new file mode 100644
index 0000000..b0c99e5
--- /dev/null
+++ b/src/features/assistant/components/MarkdownAnswer.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { MarkdownAnswer } from './MarkdownAnswer'
+
+const tableMarkdown = `
+| گروه ریسک | تعداد کاربران | حجم تاریخی کل (تومان) |
+| --- | --- | --- |
+| ریسک بالا | ۸۳ | ۱,۳۵۳,۶۵۷,۸۵۱,۱۳۴ |
+| ریسک متوسط | ۲۹۸ | ۱۲۳ |
+`
+
+describe('MarkdownAnswer tables', () => {
+ it('keeps markdown tables on a real row/column grid', () => {
+ render()
+
+ expect(screen.getByRole('columnheader', { name: 'گروه ریسک' })).toBeInTheDocument()
+ expect(screen.getByRole('columnheader', { name: 'تعداد کاربران' })).toBeInTheDocument()
+ expect(screen.getByRole('columnheader', { name: 'حجم تاریخی کل (تومان)' })).toBeInTheDocument()
+ expect(screen.getByRole('cell', { name: 'ریسک بالا' })).toBeInTheDocument()
+ expect(screen.getByRole('cell', { name: '۸۳' })).toBeInTheDocument()
+ expect(screen.getAllByRole('row')).toHaveLength(3)
+ })
+})
diff --git a/src/features/assistant/components/MarkdownAnswer.tsx b/src/features/assistant/components/MarkdownAnswer.tsx
new file mode 100644
index 0000000..fda32ec
--- /dev/null
+++ b/src/features/assistant/components/MarkdownAnswer.tsx
@@ -0,0 +1,38 @@
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import rehypeKatex from 'rehype-katex'
+import 'katex/dist/katex.min.css'
+import { resolveApiUrl } from '../../../services/apiBase'
+import { CodeBlock } from './CodeBlock'
+import { markdownTableComponents } from './markdownTable'
+
+export function MarkdownAnswer({ content }: { content: string }) {
+ if (!content) return null
+ return (
+
+
+ },
+ code({ className, children, ...props }) {
+ const match = /language-(\w+)/.exec(className || '')
+ const code = String(children).replace(/\n$/, '')
+ if (match) return
+ return (
+
+ {children}
+
+ )
+ },
+ }}
+ >
+ {content}
+
+
+ )
+}
diff --git a/src/features/assistant/components/QuestionDialog.tsx b/src/features/assistant/components/QuestionDialog.tsx
new file mode 100644
index 0000000..39bfbb5
--- /dev/null
+++ b/src/features/assistant/components/QuestionDialog.tsx
@@ -0,0 +1,34 @@
+import { Button } from '../../../components/Button'
+import type { PendingQuestion } from '../types/chat'
+
+export function QuestionDialog({
+ question,
+ onReply,
+ onReject,
+}: {
+ question: PendingQuestion
+ onReply: (text: string) => void
+ onReject: () => void
+}) {
+ return (
+
+
سؤال از طرف دستیار
+
{question.question}
+ {question.options?.length ? (
+
+ {question.options.map((opt) => (
+
+ ))}
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
diff --git a/src/features/assistant/components/StepTimeline.tsx b/src/features/assistant/components/StepTimeline.tsx
new file mode 100644
index 0000000..6df87be
--- /dev/null
+++ b/src/features/assistant/components/StepTimeline.tsx
@@ -0,0 +1,62 @@
+import { ChevronDown, ChevronUp } from 'lucide-react'
+import { useState } from 'react'
+import type { ExecutionStep } from '../types/chat'
+import { ManusStepCard } from '../execution/ManusStepCard'
+import { t } from '../../../i18n'
+
+export function StepTimeline({
+ steps,
+ activeStepId,
+ onStepClick,
+ defaultCollapsed = false,
+}: {
+ steps: ExecutionStep[]
+ activeStepId?: string | null
+ onStepClick?: (stepId: string) => void
+ defaultCollapsed?: boolean
+}) {
+ const [collapsed, setCollapsed] = useState(defaultCollapsed)
+ if (!steps.length) return null
+
+ const runningCount = steps.filter((s) => s.status === 'running').length
+ const doneCount = steps.filter((s) => s.status === 'done' || s.status === 'completed').length
+
+ return (
+
+
+ {!collapsed ? (
+
+ {steps.map((step, index) => {
+ const isLast = index === steps.length - 1
+ return (
+ -
+ {!isLast ? (
+
+ ) : null}
+ onStepClick?.(step.id)}
+ />
+
+ )
+ })}
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/components/TaskPlanCard.tsx b/src/features/assistant/components/TaskPlanCard.tsx
new file mode 100644
index 0000000..f68c0a1
--- /dev/null
+++ b/src/features/assistant/components/TaskPlanCard.tsx
@@ -0,0 +1,76 @@
+import { CheckCircle2, Circle, Loader2, MinusCircle } from 'lucide-react'
+import { useState } from 'react'
+import type { TaskItem } from '../types/chat'
+
+function StatusIcon({ status }: { status: TaskItem['status'] }) {
+ if (status === 'completed') return
+ if (status === 'in_progress') return
+ if (status === 'cancelled') return
+ return
+}
+
+export function visibleTasks(tasks: TaskItem[]): TaskItem[] {
+ return tasks.filter((task) => task.status !== 'cancelled')
+}
+
+export function TaskPlanList({
+ tasks,
+ compact = false,
+}: {
+ tasks: TaskItem[]
+ compact?: boolean
+}) {
+ const items = visibleTasks(tasks)
+ if (!items.length) return null
+
+ return (
+
+ {items.map((task, index) => {
+ const active = task.status === 'in_progress'
+ const done = task.status === 'completed'
+ return (
+ -
+
+
+
+
+ {task.content}
+
+
+ )
+ })}
+
+ )
+}
+
+export function TaskPlanCard({ tasks }: { tasks: TaskItem[] }) {
+ const [collapsed, setCollapsed] = useState(false)
+ const items = visibleTasks(tasks)
+ if (!items.length) return null
+ const done = items.filter((task) => task.status === 'completed').length
+
+ return (
+
+
+ {!collapsed ? (
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/components/markdownTable.tsx b/src/features/assistant/components/markdownTable.tsx
new file mode 100644
index 0000000..a5a34e2
--- /dev/null
+++ b/src/features/assistant/components/markdownTable.tsx
@@ -0,0 +1,80 @@
+import { isValidElement, type ReactNode } from 'react'
+import { toLatinDigits } from '../report/formatValue'
+
+function cellText(node: ReactNode): string {
+ if (node == null || typeof node === 'boolean') return ''
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
+ if (Array.isArray(node)) return node.map(cellText).join('')
+ if (isValidElement<{ children?: ReactNode }>(node)) return cellText(node.props.children)
+ return ''
+}
+
+function isNumericCell(text: string): boolean {
+ const value = toLatinDigits(text).trim()
+ if (!value || value === '—' || value === '-') return false
+ return /^[+\-−]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:\s*[%٪])?(?:\s*[\((][^))]+[\))])?$/.test(
+ value,
+ )
+}
+
+function deltaTone(text: string): 'up' | 'down' | null {
+ if (!/[%٪]|رشد|افزایش|کاهش|افت/.test(text)) return null
+ if (/[+]/.test(text) || /رشد|افزایش/.test(text)) return 'up'
+ if (/[−-]/.test(toLatinDigits(text)) || /کاهش|افت/.test(text)) return 'down'
+ return null
+}
+
+const cellBorder = 'border-s border-line first:border-s-0'
+
+function tdClassName(children: ReactNode): string {
+ const text = cellText(children).trim()
+ const numeric = isNumericCell(text)
+ if (!numeric) return `${cellBorder} whitespace-nowrap px-3.5 py-2.5 align-middle text-ink`
+
+ const tone = deltaTone(text)
+ const toneClass =
+ tone === 'up' ? 'text-success' : tone === 'down' ? 'text-danger' : 'text-ink'
+
+ return `${cellBorder} ltr-island whitespace-nowrap px-3.5 py-2.5 align-middle font-medium tabular-nums ${toneClass}`
+}
+
+export const markdownTableComponents = {
+ table({ children }: { children?: ReactNode }) {
+ return (
+
+ )
+ },
+ thead({ children }: { children?: ReactNode }) {
+ return {children}
+ },
+ tbody({ children }: { children?: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+ },
+ tr({ children }: { children?: ReactNode }) {
+ return {children}
+ },
+ th({ children }: { children?: ReactNode }) {
+ const text = cellText(children).trim()
+ const numeric = isNumericCell(text)
+ return (
+
+ {children}
+ |
+ )
+ },
+ td({ children }: { children?: ReactNode }) {
+ return {children} |
+ },
+}
diff --git a/src/features/assistant/execution/CodeExecutionView.tsx b/src/features/assistant/execution/CodeExecutionView.tsx
new file mode 100644
index 0000000..84e6bf9
--- /dev/null
+++ b/src/features/assistant/execution/CodeExecutionView.tsx
@@ -0,0 +1,168 @@
+import { lazy, Suspense, useState, type ReactNode } from 'react'
+import type { ExecutionOutput } from '../types/chat'
+import { CodeBlock } from '../components/CodeBlock'
+import { FileDownloadCard, parseDownloadableFile } from '../artifacts/FileDownloadCard'
+import { stripCodeFences } from './groupOutputs'
+import { resolveApiUrl } from '../../../services/apiBase'
+import { t } from '../../../i18n'
+
+const TablePreview = lazy(() => import('../artifacts/TablePreview').then((m) => ({ default: m.TablePreview })))
+const ChartPreview = lazy(() => import('../artifacts/ChartPreview').then((m) => ({ default: m.ChartPreview })))
+
+export function CodeExecutionView({
+ codes,
+ results,
+ images,
+ files = [],
+}: {
+ codes: ExecutionOutput[]
+ results: ExecutionOutput[]
+ images: ExecutionOutput[]
+ files?: ExecutionOutput[]
+}) {
+ const downloadable = files
+ .map((item) => parseDownloadableFile(item.content))
+ .filter((item): item is NonNullable => Boolean(item))
+ const [activeCodeIndex, setActiveCodeIndex] = useState(0)
+ const [activeView, setActiveView] = useState<'code' | 'chart'>(images.length > 0 ? 'chart' : 'code')
+
+ const codeBlocks = codes.map((c) => stripCodeFences(String(c.content)))
+ const activeCode = codeBlocks[activeCodeIndex] ?? codeBlocks[0] ?? ''
+ const textResults = results.filter((r) => r.output_type === 'text')
+ const structuredResults = results.filter((r) =>
+ r.output_type === 'json' || r.output_type === 'table' || r.output_type === 'chart',
+ )
+ const terminalText = textResults.map((r) => String(r.content)).join('')
+ const hasResults = results.length > 0 || downloadable.length > 0
+
+ const codePanel = (
+
+ {codeBlocks.length > 1 ? (
+
+ {codeBlocks.map((_, index) => (
+ setActiveCodeIndex(index)}>
+ {t('assistant.panel.codeBlock')} {index + 1}
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+
+ {hasResults ? (
+ <>
+
+
+
+
+ {downloadable.length > 0 ? (
+
+ {downloadable.map((file) => (
+
+ ))}
+
+ ) : null}
+ {terminalText ? (
+
+ {terminalText}
+
+ ) : null}
+ {structuredResults.map((output, index) => (
+
…}>
+
+
+ ))}
+
+
+ >
+ ) : null}
+
+ )
+
+ return (
+
+ {images.length > 0 ? (
+
+ setActiveView('code')}>
+ {t('assistant.panel.code')}
+
+ setActiveView('chart')}>
+ {t('assistant.panel.chart')}
+
+
+ ) : null}
+
+ {activeView === 'chart' && images.length > 0 ? (
+
+ {images.map((img, idx) => (
+
)})
+ ))}
+
+ ) : (
+ codePanel
+ )}
+
+ )
+}
+
+function StructuredResult({ output }: { output: ExecutionOutput }) {
+ if (output.output_type === 'json') {
+ const json =
+ typeof output.content === 'string'
+ ? output.content
+ : JSON.stringify(output.content, null, 2)
+ return (
+
+ )
+ }
+ if (output.output_type === 'table' && output.content && typeof output.content === 'object') {
+ return } />
+ }
+ if (output.output_type === 'chart' && output.content && typeof output.content === 'object') {
+ return } />
+ }
+ return null
+}
+
+function SectionBadge({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ )
+}
+
+function TabButton({
+ active,
+ onClick,
+ children,
+}: {
+ active: boolean
+ onClick: () => void
+ children: ReactNode
+}) {
+ return (
+
+ )
+}
diff --git a/src/features/assistant/execution/ExecutionOutputRenderer.test.tsx b/src/features/assistant/execution/ExecutionOutputRenderer.test.tsx
new file mode 100644
index 0000000..07b9747
--- /dev/null
+++ b/src/features/assistant/execution/ExecutionOutputRenderer.test.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { ExecutionOutputRenderer } from './ExecutionOutputRenderer'
+
+describe('ExecutionOutputRenderer skill load', () => {
+ it('shows the Persian catalog summary instead of English SKILL.md', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('مهارت بارگذاریشده')).toBeInTheDocument()
+ expect(screen.getByText('ریشهیاب علت')).toBeInTheDocument()
+ expect(screen.getByText(/شواهد چندبخشی را ترکیب میکند/)).toBeInTheDocument()
+ expect(screen.queryByText(/Kamidso Root Cause Investigator/)).not.toBeInTheDocument()
+ })
+
+ it('keeps the English skill body only in developer mode', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('ریشهیاب علت')).toBeInTheDocument()
+ expect(screen.getByText(/Kamidso Root Cause Investigator/)).toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/execution/ExecutionOutputRenderer.tsx b/src/features/assistant/execution/ExecutionOutputRenderer.tsx
new file mode 100644
index 0000000..aeaab8b
--- /dev/null
+++ b/src/features/assistant/execution/ExecutionOutputRenderer.tsx
@@ -0,0 +1,192 @@
+import { lazy, Suspense } from 'react'
+import type { ExecutionOutput } from '../types/chat'
+import { CodeBlock } from '../components/CodeBlock'
+import { MarkdownAnswer } from '../components/MarkdownAnswer'
+import { CodeExecutionView } from './CodeExecutionView'
+import { FileDownloadCard, parseDownloadableFile } from '../artifacts/FileDownloadCard'
+import { resolveApiUrl } from '../../../services/apiBase'
+import { t } from '../../../i18n'
+import { presentSkillByKey } from '../../skills/present'
+import { groupExecutionOutputs, stripCodeFences, type ExecutionOutputGroup } from './groupOutputs'
+
+const HtmlPreview = lazy(() => import('../components/HtmlPreview').then((m) => ({ default: m.HtmlPreview })))
+const TablePreview = lazy(() => import('../artifacts/TablePreview').then((m) => ({ default: m.TablePreview })))
+const ChartPreview = lazy(() => import('../artifacts/ChartPreview').then((m) => ({ default: m.ChartPreview })))
+const JsonPreview = lazy(() => import('../artifacts/JsonPreview').then((m) => ({ default: m.JsonPreview })))
+
+export function ExecutionOutputRenderer({
+ outputs,
+ developerMode,
+}: {
+ outputs: ExecutionOutput[]
+ developerMode?: boolean
+}) {
+ const groups = groupExecutionOutputs(outputs)
+ if (!groups.length) return null
+
+ return (
+
+ {groups.map((group, index) => (
+ …}>
+
+
+ ))}
+
+ )
+}
+
+function OutputGroup({
+ group,
+ developerMode,
+}: {
+ group: ExecutionOutputGroup
+ developerMode?: boolean
+}) {
+ if (group.type === 'skill-load') {
+ return
+ }
+ if (group.type === 'code-execution') {
+ return (
+
+ )
+ }
+ if (group.type === 'html-tabbed') {
+ const htmlContent = group.html.content
+ const html =
+ typeof htmlContent === 'object' && htmlContent && 'html' in (htmlContent as object)
+ ? String((htmlContent as { html?: string }).html ?? '')
+ : String(htmlContent ?? '')
+ return (
+
+ {group.code ? (
+
+ ) : null}
+
+
+ )
+ }
+ return
+}
+
+function SingleOutput({ output, developerMode }: { output: ExecutionOutput; developerMode?: boolean }) {
+ if (output.output_type === 'code') {
+ return (
+
+ )
+ }
+ if (output.output_type === 'html') {
+ return
+ }
+ if (output.output_type === 'table' && output.content && typeof output.content === 'object') {
+ return
+ }
+ if (output.output_type === 'chart' && output.content && typeof output.content === 'object') {
+ return } />
+ }
+ if (output.output_type === 'error') {
+ return (
+
+ {String(output.content)}
+
+ )
+ }
+ if (output.output_type === 'json') {
+ const json =
+ typeof output.content === 'string'
+ ? output.content
+ : JSON.stringify(output.content, null, 2)
+ return (
+
+ )
+ }
+ if (output.output_type === 'markdown' || output.output_type === 'text') {
+ if (output.output_type === 'text') {
+ return (
+
+
+ {t('assistant.panel.terminal')}
+
+
+ {String(output.content)}
+
+
+ )
+ }
+ return
+ }
+ if (output.output_type === 'image') {
+ return (
+
+ )
+ }
+ if (output.output_type === 'file') {
+ const file = parseDownloadableFile(output.content)
+ if (file) return
+ }
+ if (developerMode) {
+ return
+ }
+ return (
+
+ {typeof output.content === 'string' ? output.content : JSON.stringify(output.content, null, 2)}
+
+ )
+}
+
+function SkillLoadCard({
+ group,
+ developerMode,
+}: {
+ group: Extract
+ developerMode?: boolean
+}) {
+ const presented = presentSkillByKey(group.skillName)
+ const title = presented?.name || group.skillName
+ const description = presented?.description || ''
+ const domain = presented?.domain || ''
+
+ return (
+
+
+
{t('assistant.panel.skillLoaded')}
+
{title}
+ {description ?
{description}
: null}
+ {domain ?
{domain}
: null}
+
+ {developerMode ? (
+
+
+ {t('assistant.panel.terminal')}
+
+
+ {group.body}
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/execution/ExecutionPanel.tsx b/src/features/assistant/execution/ExecutionPanel.tsx
new file mode 100644
index 0000000..5a970dc
--- /dev/null
+++ b/src/features/assistant/execution/ExecutionPanel.tsx
@@ -0,0 +1,215 @@
+import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
+import { BookOpen, Database, FileText, FolderOpen, Loader2, Monitor, PanelLeftClose } from 'lucide-react'
+import type { KamidsoArtifact } from '../../../contracts/run'
+import type { ChatMessage, ExecutionOutput, ExecutionStep, TaskItem } from '../types/chat'
+import type { SessionFileSnapshot } from '../session-files/types'
+import { MarkdownAnswer } from '../components/MarkdownAnswer'
+import { StepTimeline } from '../components/StepTimeline'
+import { TaskPlanCard } from '../components/TaskPlanCard'
+import { ExecutionOutputRenderer } from './ExecutionOutputRenderer'
+import { StepDetailCard } from './StepDetailCard'
+import { PanelTabBar } from './PanelTabBar'
+import { TaskFilesTab } from './TaskFilesTab'
+import { t } from '../../../i18n'
+
+const JsonPreview = lazy(() => import('../artifacts/JsonPreview').then((m) => ({ default: m.JsonPreview })))
+
+type PanelView = 'execution' | 'files' | 'summary' | 'technical'
+
+export function ExecutionPanel({
+ steps,
+ outputs,
+ activeStepId,
+ onStepSelect,
+ taskPlan,
+ summary,
+ artifacts,
+ inputFiles = [],
+ attachedKnowledge,
+ attachedDb,
+ loading,
+ developerMode,
+ onCollapse,
+ className = '',
+}: {
+ steps: ExecutionStep[]
+ outputs: Record
+ activeStepId?: string | null
+ onStepSelect?: (stepId: string) => void
+ taskPlan?: TaskItem[]
+ summary?: string
+ artifacts?: KamidsoArtifact[]
+ inputFiles?: readonly SessionFileSnapshot[]
+ attachedKnowledge?: ChatMessage['attachedKnowledge']
+ attachedDb?: ChatMessage['attachedDb']
+ loading?: boolean
+ developerMode?: boolean
+ onCollapse?: () => void
+ className?: string
+}) {
+ const [view, setView] = useState('execution')
+ const [localStepId, setLocalStepId] = useState(null)
+
+ const resolvedStepId = activeStepId ?? localStepId ?? steps[steps.length - 1]?.id ?? null
+ const activeStep = steps.find((s) => s.id === resolvedStepId)
+ const stepOutputs = resolvedStepId ? outputs[resolvedStepId] || [] : []
+ const fileCount = inputFiles.length + (artifacts?.length ?? 0)
+ const hasSources = Boolean(attachedKnowledge?.name || attachedDb?.db_name)
+
+ useEffect(() => {
+ if (activeStepId) setLocalStepId(activeStepId)
+ else if (!localStepId && steps.length) {
+ setLocalStepId(steps[steps.length - 1].id)
+ }
+ }, [activeStepId, steps, localStepId])
+
+ useEffect(() => {
+ if (loading) setView('execution')
+ }, [loading])
+
+ useEffect(() => {
+ if (activeStepId) setView('execution')
+ }, [activeStepId])
+
+ const tabs = useMemo(
+ () =>
+ [
+ { id: 'execution' as const, label: t('assistant.panel.executionSteps'), icon: Monitor },
+ fileCount > 0
+ ? { id: 'files' as const, label: t('assistant.panel.taskFiles'), icon: FolderOpen, badge: fileCount }
+ : null,
+ summary?.trim()
+ ? { id: 'summary' as const, label: t('assistant.panel.summary'), icon: FileText }
+ : null,
+ developerMode
+ ? { id: 'technical' as const, label: t('assistant.panel.technical'), icon: Monitor }
+ : null,
+ ].filter(Boolean) as Array<{ id: PanelView; label: string; icon: typeof Monitor; badge?: number }>,
+ [fileCount, summary, developerMode],
+ )
+
+ return (
+
+
+
+
+ {t('assistant.execution.title')}
+ {loading ? : null}
+
+ {onCollapse ? (
+
+ ) : null}
+
+
+ {tabs.length > 1 ?
setView(id as PanelView)} /> : null}
+
+
+ {view === 'execution' ? (
+
+ {hasSources ? (
+
+ {t('assistant.panel.sourcesThisTurn')}
+
+ {attachedKnowledge?.name ? (
+
+
+
+ {t('assistant.panel.attachedKnowledge')}: {attachedKnowledge.name}
+
+
+ ) : null}
+ {attachedDb?.db_name ? (
+
+
+
+ {t('assistant.composer.database')}: {attachedDb.db_name}
+
+
+ ) : null}
+
+
+ ) : null}
+ {taskPlan?.length ?
: null}
+
+ {steps.length ? (
+
{
+ setLocalStepId(stepId)
+ onStepSelect?.(stepId)
+ }}
+ />
+ ) : null}
+
+ {activeStep ? : null}
+
+ {stepOutputs.length ? (
+
+
+
+ ) : loading ? (
+
+
+
{t('assistant.execution.processing')}
+
+ ) : steps.length ? (
+ {t('assistant.panel.noOutput')}
+ ) : (
+ {t('assistant.panel.noSteps')}
+ )}
+
+ {steps.length > 1 ? (
+
+ {steps.map((step, index) => (
+
+ ))}
+
+ ) : null}
+
+ ) : null}
+
+ {view === 'files' ? (
+
+ ) : null}
+
+ {view === 'summary' ? (
+ summary?.trim() ? (
+
+
+
+ ) : (
+
{t('assistant.panel.summaryPlaceholder')}
+ )
+ ) : null}
+
+ {view === 'technical' ? (
+
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/features/assistant/execution/ManusStepCard.tsx b/src/features/assistant/execution/ManusStepCard.tsx
new file mode 100644
index 0000000..9cacb60
--- /dev/null
+++ b/src/features/assistant/execution/ManusStepCard.tsx
@@ -0,0 +1,62 @@
+import { CheckCircle2, Circle, Loader2, XCircle } from 'lucide-react'
+import type { ExecutionStep } from '../types/chat'
+import { stepCategoryLabel, stepTypeLabel, toolActionName } from '../adapters/stepLabels'
+import { toolIconForStep } from './toolIcons'
+
+export function ManusStepCard({
+ step,
+ active,
+ onClick,
+ compact,
+}: {
+ step: ExecutionStep
+ active?: boolean
+ onClick?: () => void
+ compact?: boolean
+}) {
+ const label = stepTypeLabel(step.action, step.title)
+ const category = stepCategoryLabel(step.action)
+ const actionName = toolActionName(step.action)
+ const Icon = toolIconForStep(step)
+ const spinning = step.status === 'running'
+
+ return (
+
+ )
+}
diff --git a/src/features/assistant/execution/PanelTabBar.tsx b/src/features/assistant/execution/PanelTabBar.tsx
new file mode 100644
index 0000000..5e5b5a9
--- /dev/null
+++ b/src/features/assistant/execution/PanelTabBar.tsx
@@ -0,0 +1,50 @@
+import type { LucideIcon } from 'lucide-react'
+
+export type PanelTabItem = {
+ id: string
+ label: string
+ icon: LucideIcon
+ badge?: number
+}
+
+export function PanelTabBar({
+ tabs,
+ active,
+ onChange,
+}: {
+ tabs: PanelTabItem[]
+ active: string
+ onChange: (id: string) => void
+}) {
+ return (
+
+ {tabs.map((tab) => {
+ const Icon = tab.icon
+ const isActive = active === tab.id
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/features/assistant/execution/ParallelTasksPanel.tsx b/src/features/assistant/execution/ParallelTasksPanel.tsx
new file mode 100644
index 0000000..78535d4
--- /dev/null
+++ b/src/features/assistant/execution/ParallelTasksPanel.tsx
@@ -0,0 +1,13 @@
+/** Shown only when backend emits parallel task events. */
+export function ParallelTasksPanel({ tasks }: { tasks?: Array<{ id: string; title: string; status: string }> }) {
+ if (!tasks?.length) return null
+ return (
+
+ {tasks.map((task) => (
+ -
+ {task.title} — {task.status}
+
+ ))}
+
+ )
+}
diff --git a/src/features/assistant/execution/StatusBadge.tsx b/src/features/assistant/execution/StatusBadge.tsx
new file mode 100644
index 0000000..cb4a9d3
--- /dev/null
+++ b/src/features/assistant/execution/StatusBadge.tsx
@@ -0,0 +1,26 @@
+import { t } from '../../../i18n'
+import type { ExecutionStep } from '../types/chat'
+
+export function StatusBadge({ status }: { status: ExecutionStep['status'] }) {
+ const styles: Record = {
+ running: 'bg-blue-50 text-blue-600 ring-blue-200/60 dark:bg-blue-500/10 dark:text-blue-300 dark:ring-blue-500/20',
+ done: 'bg-emerald-50 text-emerald-600 ring-emerald-200/60 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/20',
+ completed: 'bg-emerald-50 text-emerald-600 ring-emerald-200/60 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/20',
+ failed: 'bg-red-50 text-red-600 ring-red-200/60 dark:bg-red-500/10 dark:text-red-300 dark:ring-red-500/20',
+ pending: 'bg-slate-100 text-slate-500 ring-slate-200/60 dark:bg-white/5 dark:text-slate-400 dark:ring-white/10',
+ }
+
+ const labels: Record = {
+ running: t('assistant.panel.statusRunning'),
+ done: t('assistant.panel.statusDone'),
+ completed: t('assistant.panel.statusDone'),
+ failed: t('assistant.panel.statusFailed'),
+ pending: t('assistant.panel.statusPending'),
+ }
+
+ return (
+
+ {labels[status] ?? status}
+
+ )
+}
diff --git a/src/features/assistant/execution/StepDetailCard.tsx b/src/features/assistant/execution/StepDetailCard.tsx
new file mode 100644
index 0000000..3edc6f3
--- /dev/null
+++ b/src/features/assistant/execution/StepDetailCard.tsx
@@ -0,0 +1,55 @@
+import { ChevronDown, ChevronUp } from 'lucide-react'
+import { useState } from 'react'
+import type { ExecutionStep } from '../types/chat'
+import { stepCategoryLabel, stepDetailLabel, stepTypeLabel, toolActionName } from '../adapters/stepLabels'
+import { toolIconForStep } from './toolIcons'
+import { StatusBadge } from './StatusBadge'
+
+export function StepDetailCard({ step }: { step: ExecutionStep }) {
+ const [collapsed, setCollapsed] = useState(false)
+ const Icon = toolIconForStep(step)
+ const category = stepCategoryLabel(step.action)
+ const actionName = toolActionName(step.action)
+ const title = stepTypeLabel(step.action, step.title)
+ const detail = stepDetailLabel(step.detail)
+
+ return (
+
+
+ {!collapsed && detail ? (
+
+ {detail}
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/execution/SubAgentSection.tsx b/src/features/assistant/execution/SubAgentSection.tsx
new file mode 100644
index 0000000..9da5b81
--- /dev/null
+++ b/src/features/assistant/execution/SubAgentSection.tsx
@@ -0,0 +1,13 @@
+/** Shown only when backend emits sub-agent events. */
+export function SubAgentSection({ agents }: { agents?: Array<{ id: string; name: string; status: string }> }) {
+ if (!agents?.length) return null
+ return (
+
+ {agents.map((agent) => (
+
+ {agent.name} — {agent.status}
+
+ ))}
+
+ )
+}
diff --git a/src/features/assistant/execution/TaskFilesTab.tsx b/src/features/assistant/execution/TaskFilesTab.tsx
new file mode 100644
index 0000000..9a1295b
--- /dev/null
+++ b/src/features/assistant/execution/TaskFilesTab.tsx
@@ -0,0 +1,112 @@
+import { Download, FileSpreadsheet, FolderOpen, Image as ImageIcon } from 'lucide-react'
+import type { KamidsoArtifact } from '../../../contracts/run'
+import type { SessionFileSnapshot } from '../session-files/types'
+import { FileDownloadCard, parseDownloadableFile } from '../artifacts/FileDownloadCard'
+import { resolveApiUrl } from '../../../services/apiBase'
+import { t } from '../../../i18n'
+
+function formatSize(bytes?: number): string {
+ if (!bytes) return ''
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
+}
+
+export function TaskFilesTab({
+ inputFiles,
+ artifacts,
+}: {
+ inputFiles: readonly SessionFileSnapshot[]
+ artifacts?: KamidsoArtifact[]
+}) {
+ const hasInput = inputFiles.length > 0
+ const hasArtifacts = (artifacts?.length ?? 0) > 0
+
+ if (!hasInput && !hasArtifacts) {
+ return (
+
+
+
{t('assistant.panel.noTaskFiles')}
+
+ )
+ }
+
+ return (
+
+ {hasInput ? (
+
+
+
+ {t('assistant.panel.uploadedFiles')} · {inputFiles.length}
+
+
+
+ {inputFiles.map((file) => (
+
+
+
+
+
+
{file.name}
+
+ {file.media_type || 'file'}
+ {file.size ? ` · ${formatSize(file.size)}` : ''}
+
+
+
+ ))}
+
+
+ ) : null}
+
+ {hasArtifacts ? (
+
+
+
+ {t('assistant.panel.generatedOutputs')} · {artifacts!.length}
+
+
+
+ {artifacts!.map((artifact) => {
+ const download = artifact.type === 'file' ? parseDownloadableFile(artifact.content) : null
+ if (download) {
+ return
+ }
+ const imageUrl =
+ artifact.type === 'image' && typeof artifact.content === 'string'
+ ? resolveApiUrl(artifact.content)
+ : null
+ return (
+
+ )
+ })}
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/execution/TaskPlanCard.tsx b/src/features/assistant/execution/TaskPlanCard.tsx
new file mode 100644
index 0000000..2e77229
--- /dev/null
+++ b/src/features/assistant/execution/TaskPlanCard.tsx
@@ -0,0 +1 @@
+export { TaskPlanCard } from '../components/TaskPlanCard'
diff --git a/src/features/assistant/execution/ThinkingProcess.tsx b/src/features/assistant/execution/ThinkingProcess.tsx
new file mode 100644
index 0000000..10ba776
--- /dev/null
+++ b/src/features/assistant/execution/ThinkingProcess.tsx
@@ -0,0 +1,47 @@
+import { useEffect, useState } from 'react'
+
+export function ThinkingProcess({
+ content,
+ enableAnimation = true,
+ animationSpeed = 30,
+ className = '',
+}: {
+ content: string
+ enableAnimation?: boolean
+ animationSpeed?: number
+ className?: string
+}) {
+ const [displayedText, setDisplayedText] = useState('')
+ const [isComplete, setIsComplete] = useState(false)
+
+ useEffect(() => {
+ if (!enableAnimation) {
+ setDisplayedText(content)
+ setIsComplete(true)
+ return
+ }
+ setDisplayedText('')
+ setIsComplete(false)
+ let index = 0
+ const timer = setInterval(() => {
+ if (index < content.length) {
+ const chunkSize = Math.min(3, content.length - index)
+ setDisplayedText((prev) => prev + content.slice(index, index + chunkSize))
+ index += chunkSize
+ } else {
+ setIsComplete(true)
+ clearInterval(timer)
+ }
+ }, animationSpeed)
+ return () => clearInterval(timer)
+ }, [content, enableAnimation, animationSpeed])
+
+ if (!content) return null
+
+ return (
+
+ {displayedText}
+ {!isComplete ? : null}
+
+ )
+}
diff --git a/src/features/assistant/execution/TracePanel.tsx b/src/features/assistant/execution/TracePanel.tsx
new file mode 100644
index 0000000..b80ff71
--- /dev/null
+++ b/src/features/assistant/execution/TracePanel.tsx
@@ -0,0 +1,11 @@
+/** Developer-only trace panel — renders only when backend provides trace data. */
+export function TracePanel({ trace }: { trace?: unknown[] }) {
+ if (!trace?.length) {
+ return Trace در دسترس نیست.
+ }
+ return (
+
+ {JSON.stringify(trace, null, 2)}
+
+ )
+}
diff --git a/src/features/assistant/execution/groupOutputs.test.ts b/src/features/assistant/execution/groupOutputs.test.ts
new file mode 100644
index 0000000..a857be6
--- /dev/null
+++ b/src/features/assistant/execution/groupOutputs.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest'
+import { groupExecutionOutputs, stripCodeFences } from './groupOutputs'
+
+describe('groupExecutionOutputs', () => {
+ it('groups code followed by text into code-execution', () => {
+ const groups = groupExecutionOutputs([
+ { output_type: 'code', content: 'print(1)' },
+ { output_type: 'text', content: '1\n' },
+ ])
+ expect(groups).toHaveLength(1)
+ expect(groups[0].type).toBe('code-execution')
+ if (groups[0].type === 'code-execution') {
+ expect(groups[0].codes).toHaveLength(1)
+ expect(groups[0].results).toHaveLength(1)
+ }
+ })
+
+ it('groups code followed by json and table into code-execution', () => {
+ const groups = groupExecutionOutputs([
+ { output_type: 'code', content: 'print("hi")' },
+ { output_type: 'json', content: { shape: [1, 2] } },
+ { output_type: 'table', content: { columns: ['a'], rows: [['1']] } },
+ ])
+ expect(groups).toHaveLength(1)
+ expect(groups[0].type).toBe('code-execution')
+ if (groups[0].type === 'code-execution') {
+ expect(groups[0].results).toHaveLength(2)
+ expect(groups[0].results[0].output_type).toBe('json')
+ expect(groups[0].results[1].output_type).toBe('table')
+ }
+ })
+
+ it('groups code followed by file export chunks', () => {
+ const groups = groupExecutionOutputs([
+ { output_type: 'code', content: 'df.to_excel(...)' },
+ { output_type: 'text', content: 'saved\n' },
+ { output_type: 'file', content: { url: '/files/a_buyers.xlsx', name: 'buyers.xlsx' } },
+ { output_type: 'text', content: 'Generated downloadable files…' },
+ ])
+ expect(groups).toHaveLength(1)
+ expect(groups[0].type).toBe('code-execution')
+ if (groups[0].type === 'code-execution') {
+ expect(groups[0].files).toHaveLength(1)
+ expect(groups[0].files[0].content).toEqual({
+ url: '/files/a_buyers.xlsx',
+ name: 'buyers.xlsx',
+ })
+ expect(groups[0].results).toHaveLength(2)
+ }
+ })
+
+ it('groups load_skill terminal text with following markdown', () => {
+ const groups = groupExecutionOutputs([
+ { output_type: 'text', content: 'Skill: root-cause-investigator' },
+ { output_type: 'text', content: 'File path: ---' },
+ { output_type: 'text', content: '---' },
+ { output_type: 'markdown', content: '# Kamidso Root Cause Investigator' },
+ ])
+ expect(groups).toHaveLength(1)
+ expect(groups[0].type).toBe('skill-load')
+ if (groups[0].type === 'skill-load') {
+ expect(groups[0].skillName).toBe('root-cause-investigator')
+ expect(groups[0].body).toContain('Kamidso Root Cause Investigator')
+ }
+ })
+})
+
+describe('stripCodeFences', () => {
+ it('removes markdown fences', () => {
+ expect(stripCodeFences('```python\nx=1\n```')).toBe('x=1\n')
+ })
+})
diff --git a/src/features/assistant/execution/groupOutputs.ts b/src/features/assistant/execution/groupOutputs.ts
new file mode 100644
index 0000000..7ce318d
--- /dev/null
+++ b/src/features/assistant/execution/groupOutputs.ts
@@ -0,0 +1,136 @@
+import type { ExecutionOutput } from '../types/chat'
+import { isLoadSkillText, parseLoadSkillName } from './skillOutput'
+
+export type ExecutionOutputGroup =
+ | {
+ type: 'code-execution'
+ codes: ExecutionOutput[]
+ results: ExecutionOutput[]
+ images: ExecutionOutput[]
+ files: ExecutionOutput[]
+ }
+ | { type: 'html-tabbed'; code?: ExecutionOutput; html: ExecutionOutput }
+ | {
+ type: 'skill-load'
+ skillName: string
+ body: string
+ rawOutputs: ExecutionOutput[]
+ }
+ | { type: 'single'; output: ExecutionOutput }
+
+/** Group step outputs the same way upstream DBGPT ManusRightPanel does. */
+export function groupExecutionOutputs(outputs: ExecutionOutput[]): ExecutionOutputGroup[] {
+ const groups: ExecutionOutputGroup[] = []
+ let i = 0
+ while (i < outputs.length) {
+ if (outputs[i].output_type === 'code') {
+ const codes: ExecutionOutput[] = [outputs[i]]
+ i += 1
+ while (i < outputs.length && outputs[i].output_type === 'code') {
+ codes.push(outputs[i])
+ i += 1
+ }
+ if (i < outputs.length && outputs[i].output_type === 'html') {
+ groups.push({
+ type: 'html-tabbed',
+ code: { ...codes[0], content: codes.map((c) => String(c.content)).join('\n') },
+ html: outputs[i],
+ })
+ i += 1
+ } else {
+ const results: ExecutionOutput[] = []
+ while (
+ i < outputs.length &&
+ (outputs[i].output_type === 'text' ||
+ outputs[i].output_type === 'json' ||
+ outputs[i].output_type === 'table' ||
+ outputs[i].output_type === 'chart')
+ ) {
+ results.push(outputs[i])
+ i += 1
+ }
+ const images: ExecutionOutput[] = []
+ while (i < outputs.length && outputs[i].output_type === 'image') {
+ images.push(outputs[i])
+ i += 1
+ }
+ const files: ExecutionOutput[] = []
+ while (i < outputs.length && outputs[i].output_type === 'file') {
+ files.push(outputs[i])
+ i += 1
+ }
+ // File chunks may appear before trailing text summary — also accept interleaved.
+ while (
+ i < outputs.length &&
+ (outputs[i].output_type === 'file' || outputs[i].output_type === 'text')
+ ) {
+ if (outputs[i].output_type === 'file') files.push(outputs[i])
+ else results.push(outputs[i])
+ i += 1
+ }
+ groups.push({ type: 'code-execution', codes, results, images, files })
+ }
+ } else if (outputs[i].output_type === 'html') {
+ groups.push({ type: 'html-tabbed', html: outputs[i] })
+ i += 1
+ } else if (outputs[i].output_type === 'markdown') {
+ const markdownParts: string[] = [String(outputs[i].content)]
+ const firstMarkdown = outputs[i]
+ i += 1
+ while (i < outputs.length && outputs[i].output_type === 'markdown') {
+ markdownParts.push(String(outputs[i].content))
+ i += 1
+ }
+ groups.push({ type: 'single', output: { ...firstMarkdown, content: markdownParts.join('\n\n') } })
+ } else if (outputs[i].output_type === 'text') {
+ const firstText = outputs[i]
+ const firstContent = String(firstText.content ?? '')
+ if (isLoadSkillText(firstContent)) {
+ const rawOutputs: ExecutionOutput[] = [firstText]
+ const parts = [firstContent]
+ i += 1
+ while (i < outputs.length && outputs[i].output_type === 'text') {
+ rawOutputs.push(outputs[i])
+ parts.push(String(outputs[i].content ?? ''))
+ i += 1
+ }
+ while (i < outputs.length && outputs[i].output_type === 'markdown') {
+ rawOutputs.push(outputs[i])
+ parts.push(String(outputs[i].content ?? ''))
+ i += 1
+ }
+ groups.push({
+ type: 'skill-load',
+ skillName: parseLoadSkillName(firstContent) || '',
+ body: parts.join('\n\n'),
+ rawOutputs,
+ })
+ } else {
+ const textParts: string[] = [firstContent]
+ i += 1
+ while (i < outputs.length && outputs[i].output_type === 'text') {
+ textParts.push(String(outputs[i].content))
+ i += 1
+ }
+ groups.push({ type: 'single', output: { ...firstText, content: textParts.join('\n\n') } })
+ }
+ } else if (outputs[i].output_type === 'error') {
+ const errorParts: string[] = [String(outputs[i].content)]
+ const firstError = outputs[i]
+ i += 1
+ while (i < outputs.length && outputs[i].output_type === 'error') {
+ errorParts.push(String(outputs[i].content))
+ i += 1
+ }
+ groups.push({ type: 'single', output: { ...firstError, content: errorParts.join('') } })
+ } else {
+ groups.push({ type: 'single', output: outputs[i] })
+ i += 1
+ }
+ }
+ return groups
+}
+
+export function stripCodeFences(code: string): string {
+ return code.replace(/^\s*```[a-zA-Z]*\s*/m, '').replace(/```\s*$/m, '')
+}
diff --git a/src/features/assistant/execution/skillOutput.ts b/src/features/assistant/execution/skillOutput.ts
new file mode 100644
index 0000000..d717dfc
--- /dev/null
+++ b/src/features/assistant/execution/skillOutput.ts
@@ -0,0 +1,11 @@
+const SKILL_LINE = /^\s*Skill:\s*(.+?)\s*$/m
+
+export function parseLoadSkillName(content: string): string | null {
+ const match = content.match(SKILL_LINE)
+ const name = match?.[1]?.trim()
+ return name || null
+}
+
+export function isLoadSkillText(content: string): boolean {
+ return Boolean(parseLoadSkillName(content))
+}
diff --git a/src/features/assistant/execution/toolIcons.tsx b/src/features/assistant/execution/toolIcons.tsx
new file mode 100644
index 0000000..a9b9025
--- /dev/null
+++ b/src/features/assistant/execution/toolIcons.tsx
@@ -0,0 +1,37 @@
+import {
+ BookOpen,
+ Code2,
+ Database,
+ FileSearch,
+ FileSpreadsheet,
+ Loader2,
+ Search,
+ Terminal,
+ Wrench,
+ type LucideIcon,
+} from 'lucide-react'
+import type { ExecutionStep } from '../types/chat'
+
+const ACTION_ICONS: Record = {
+ code_interpreter: Code2,
+ execute_analysis: Terminal,
+ load_file: FileSpreadsheet,
+ file_reader: FileSearch,
+ sql_executor: Database,
+ knowledge_search: Search,
+ read_file: FileSearch,
+ write_file: FileSpreadsheet,
+ run_python: Code2,
+}
+
+export function toolIconForStep(step: ExecutionStep): LucideIcon {
+ if (step.status === 'running') return Loader2
+ const action = step.action?.toLowerCase() ?? ''
+ for (const [key, icon] of Object.entries(ACTION_ICONS)) {
+ if (action.includes(key)) return icon
+ }
+ if (step.title?.includes('فایل') || step.title?.includes('file')) return FileSpreadsheet
+ if (step.title?.includes('SQL') || step.title?.includes('داده')) return Database
+ if (step.title?.includes('دانش')) return BookOpen
+ return Wrench
+}
diff --git a/src/features/assistant/files/index.ts b/src/features/assistant/files/index.ts
new file mode 100644
index 0000000..6951d74
--- /dev/null
+++ b/src/features/assistant/files/index.ts
@@ -0,0 +1,21 @@
+/** Workspace files module — Kamidso naming over session-files implementation. */
+export type {
+ KamidsoFile,
+ SessionFileSnapshot as WorkspaceFileSnapshot,
+ DraftFile,
+ LegacyServerFile,
+ UploadCapabilities,
+ SessionFilesSendSnapshot as WorkspaceFilesSendSnapshot,
+} from '../session-files/types'
+
+export {
+ sessionFilesApi as workspaceFilesApi,
+ previewFile,
+ snapshotsForSend,
+ useSessionFiles as useFiles,
+} from '../session-files'
+
+export { default as AttachmentRail, AttachmentRailAddButton } from '../session-files/AttachmentRail'
+export { default as AttachmentPreview, AttachmentPreviewCloseButton, AttachmentPreviewPanelTitle } from '../session-files/AttachmentPreview'
+export { default as AttachmentMessageCards } from '../session-files/AttachmentMessageCards'
+export { default as AttachmentMessageGroup } from '../session-files/AttachmentMessageGroup'
diff --git a/src/features/assistant/hooks/useAssistantStream.ts b/src/features/assistant/hooks/useAssistantStream.ts
new file mode 100644
index 0000000..409a99f
--- /dev/null
+++ b/src/features/assistant/hooks/useAssistantStream.ts
@@ -0,0 +1,2 @@
+/** Backward-compatible alias — prefer `useRunStream` from `../stream/useRunStream`. */
+export { useRunStream as useAssistantStream } from '../stream/useRunStream'
diff --git a/src/features/assistant/input/EnhancedChatInput.tsx b/src/features/assistant/input/EnhancedChatInput.tsx
new file mode 100644
index 0000000..4587106
--- /dev/null
+++ b/src/features/assistant/input/EnhancedChatInput.tsx
@@ -0,0 +1,165 @@
+import { Paperclip, Send, Square } from 'lucide-react'
+import { useEffect, useRef, useState } from 'react'
+import { t } from '../../../i18n'
+import AttachmentRail from '../session-files/AttachmentRail'
+import type { DraftFile, LegacyServerFile } from '../session-files'
+import { conversationWidthClass } from '../workspace/conversationLayout'
+import { DATA_FILE_ACCEPT } from '../session-files/fileKind'
+import type { ResourceOption } from '../types/chat'
+
+export function EnhancedChatInput({
+ query,
+ onQueryChange,
+ onSend,
+ onStop,
+ loading,
+ drafts,
+ legacyFiles,
+ onRemoveFile,
+ onRetryFile,
+ onAddFiles,
+ pinnedDatabases = [],
+ pinnedKnowledge = null,
+ onRemoveDatabase,
+ onClearKnowledge,
+ toolbar,
+ variant = 'dock',
+}: {
+ query: string
+ onQueryChange: (value: string) => void
+ onSend: () => void
+ onStop: () => void
+ loading: boolean
+ drafts: readonly DraftFile[]
+ legacyFiles?: readonly LegacyServerFile[]
+ onRemoveFile: (clientId: string) => void
+ onRetryFile: (clientId: string) => void
+ onAddFiles: (files: FileList | File[]) => void
+ pinnedDatabases?: readonly ResourceOption[]
+ pinnedKnowledge?: ResourceOption | null
+ onRemoveDatabase?: (id: string) => void
+ onClearKnowledge?: () => void
+ toolbar?: React.ReactNode
+ variant?: 'hero' | 'dock'
+}) {
+ const fileInputRef = useRef(null)
+ const textareaRef = useRef(null)
+ const [focused, setFocused] = useState(false)
+ const isHero = variant === 'hero'
+ const canSend = Boolean(query.trim() || drafts.length > 0 || (legacyFiles?.length ?? 0) > 0)
+ const hasPins = Boolean(
+ drafts.length ||
+ (legacyFiles?.length ?? 0) ||
+ pinnedDatabases.length ||
+ pinnedKnowledge,
+ )
+
+ useEffect(() => {
+ const el = textareaRef.current
+ if (!el) return
+ el.style.height = 'auto'
+ el.style.height = `${Math.min(Math.max(el.scrollHeight, 40), 132)}px`
+ }, [query])
+
+ return (
+
+
+
+ {hasPins ? (
+
+ ) : null}
+
+
+
+
+
+ {toolbar ? (
+
+ {toolbar}
+
+ ) : null}
+
+
+
+
{
+ if (e.target.files?.length) onAddFiles(Array.from(e.target.files))
+ e.target.value = ''
+ }}
+ />
+
+ )
+}
diff --git a/src/features/assistant/input/ModelSwitcher.tsx b/src/features/assistant/input/ModelSwitcher.tsx
new file mode 100644
index 0000000..4480dcf
--- /dev/null
+++ b/src/features/assistant/input/ModelSwitcher.tsx
@@ -0,0 +1,33 @@
+import { Cpu } from 'lucide-react'
+import { SelectMenu } from '../../../components/ui/SelectMenu'
+
+const PILL =
+ 'h-8 max-w-[8.5rem] rounded-full border-line/80 bg-transparent px-2 text-xs hover:border-accent/30 hover:bg-white'
+
+export function ModelSwitcher({
+ models,
+ value,
+ onChange,
+}: {
+ models: string[]
+ value?: string
+ onChange: (model: string) => void
+}) {
+ const options = (models.length ? models : ['auto']).map((m) => ({
+ value: m,
+ label: m === 'auto' ? 'خودکار' : m,
+ }))
+
+ return (
+ }
+ minMenuWidth={180}
+ triggerClassName={PILL}
+ />
+ )
+}
diff --git a/src/features/assistant/input/ResourcePicker.test.tsx b/src/features/assistant/input/ResourcePicker.test.tsx
new file mode 100644
index 0000000..5624170
--- /dev/null
+++ b/src/features/assistant/input/ResourcePicker.test.tsx
@@ -0,0 +1,104 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import { ResourcePicker } from './ResourcePicker'
+import type { KamidsoFile } from '../session-files'
+
+const book: KamidsoFile = {
+ id: 'file_book1',
+ name: 'Book1 (1).xlsx',
+ title: 'Book1 (1)',
+ size_bytes: 1200,
+ mime_type: 'application/vnd.ms-excel',
+ status: 'ready',
+ created_at: '2026-01-01',
+ kind: 'excel',
+}
+
+const sales: KamidsoFile = {
+ id: 'file_sales',
+ name: 'sales.xlsx',
+ title: 'sales',
+ size_bytes: 800,
+ mime_type: 'application/vnd.ms-excel',
+ status: 'ready',
+ created_at: '2026-01-01',
+ kind: 'excel',
+}
+
+const policy: KamidsoFile = {
+ id: 'file_policy',
+ name: 'policy.pdf',
+ title: 'سیاست',
+ size_bytes: 2400,
+ mime_type: 'application/pdf',
+ status: 'ready',
+ created_at: '2026-01-01',
+ kind: 'pdf',
+}
+
+describe('ResourcePicker', () => {
+ it('merges connections and data files, and hides knowledge documents', async () => {
+ const onToggle = vi.fn()
+ const onSelectDbs = vi.fn()
+ render(
+ ,
+ )
+
+ await userEvent.click(screen.getByRole('button', { name: 'داده' }))
+ expect(screen.getByRole('option', { name: /فروش/ })).toBeInTheDocument()
+ expect(screen.getByRole('option', { name: /Book1 \(1\)/ })).toBeInTheDocument()
+ expect(screen.queryByRole('option', { name: /سیاست/ })).not.toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('option', { name: /Book1 \(1\)/ }))
+ expect(onToggle).toHaveBeenCalledWith(book)
+ })
+
+ it('keeps the menu open so several databases and files can be attached', async () => {
+ const onToggle = vi.fn()
+ const onSelectDbs = vi.fn()
+ render(
+ ,
+ )
+
+ await userEvent.click(screen.getByRole('button', { name: 'داده' }))
+ expect(screen.getByRole('option', { name: /فروش/ })).toHaveAttribute('aria-selected', 'true')
+ expect(screen.getByRole('option', { name: /Book1 \(1\)/ })).toHaveAttribute('aria-selected', 'true')
+
+ await userEvent.click(screen.getByRole('option', { name: /عملیات/ }))
+ expect(onSelectDbs).toHaveBeenCalledWith([
+ { id: 'db_sales', name: 'فروش', type: 'postgres' },
+ { id: 'db_ops', name: 'عملیات', type: 'mongodb' },
+ ])
+ expect(screen.getByRole('option', { name: /فروش/ })).toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('option', { name: /sales/ }))
+ expect(onToggle).toHaveBeenCalledWith(sales)
+ expect(screen.getByRole('listbox', { name: 'داده' })).toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/input/ResourcePicker.tsx b/src/features/assistant/input/ResourcePicker.tsx
new file mode 100644
index 0000000..a8d8c4b
--- /dev/null
+++ b/src/features/assistant/input/ResourcePicker.tsx
@@ -0,0 +1,147 @@
+import { BookOpen, Database, FileSpreadsheet, Upload } from 'lucide-react'
+import { t } from '../../../i18n'
+import { SelectMenu } from '../../../components/ui/SelectMenu'
+import type { ResourceOption } from '../types/chat'
+import { ToolbarChipIcon, toolbarChipClass } from './toolbarChip'
+import type { KamidsoFile } from '../session-files'
+import { fileKindLine, isDataFileKind } from '../session-files/fileKind'
+
+const CONN_PREFIX = 'conn:'
+const FILE_PREFIX = 'file:'
+
+export function ResourcePicker({
+ databases,
+ knowledge,
+ selectedDbs,
+ selectedKnowledge,
+ onSelectDbs,
+ onSelectKnowledge,
+ libraryFiles = [],
+ attachedFileIds = [],
+ onToggleLibraryFile,
+ onUploadNew,
+}: {
+ databases: ResourceOption[]
+ knowledge: ResourceOption[]
+ selectedDbs: ResourceOption[]
+ selectedKnowledge: ResourceOption | null
+ onSelectDbs: (items: ResourceOption[]) => void
+ onSelectKnowledge: (item: ResourceOption | null) => void
+ libraryFiles?: KamidsoFile[]
+ attachedFileIds?: readonly string[]
+ onToggleLibraryFile?: (file: KamidsoFile) => void
+ onUploadNew?: () => void
+}) {
+ const dataFiles = libraryFiles.filter((file) => isDataFileKind(file.kind))
+ const attached = dataFiles.filter((file) => attachedFileIds.includes(file.id))
+ const names = [
+ ...selectedDbs.map((item) => item.name),
+ ...attached.map((file) => file.title?.trim() || file.name),
+ ]
+ const dataActive = names.length > 0
+ const selectedDataValues = dataActive
+ ? [
+ ...selectedDbs.map((item) => `${CONN_PREFIX}${item.id}`),
+ ...attached.map((file) => `${FILE_PREFIX}${file.id}`),
+ ]
+ : ['']
+ const dataValue = selectedDataValues[0] && selectedDataValues[0] !== '' ? selectedDataValues[0] : ''
+
+ return (
+
+ ({
+ value: `${CONN_PREFIX}${item.id}`,
+ label: item.name,
+ description: item.type || t('assistant.composer.dataConnection'),
+ group: t('assistant.composer.dataConnections'),
+ })),
+ ...dataFiles.map((file) => ({
+ value: `${FILE_PREFIX}${file.id}`,
+ label: file.title?.trim() || file.name,
+ description: fileKindLine(file.kind),
+ group: t('assistant.composer.dataFiles'),
+ })),
+ ]}
+ onChange={(id) => {
+ if (!id) {
+ onSelectDbs([])
+ attached.forEach((file) => onToggleLibraryFile?.(file))
+ return
+ }
+ if (id.startsWith(CONN_PREFIX)) {
+ const next = databases.find((item) => item.id === id.slice(CONN_PREFIX.length))
+ if (!next) return
+ const exists = selectedDbs.some((item) => item.id === next.id)
+ onSelectDbs(exists ? selectedDbs.filter((item) => item.id !== next.id) : [...selectedDbs, next])
+ return
+ }
+ if (id.startsWith(FILE_PREFIX) && onToggleLibraryFile) {
+ const file = dataFiles.find((item) => item.id === id.slice(FILE_PREFIX.length))
+ if (file) onToggleLibraryFile(file)
+ }
+ }}
+ leading={
+
+ }
+ trailing={
+ names.length > 0 ? (
+
+ {names.length}
+
+ ) : null
+ }
+ minMenuWidth={280}
+ emptyLabel={t('assistant.composer.dataEmpty')}
+ triggerClassName={toolbarChipClass(dataActive)}
+ footer={
+ onUploadNew ? (
+
+ ) : null
+ }
+ />
+ ({ value: item.id, label: item.name })),
+ ]}
+ onChange={(id) => onSelectKnowledge(knowledge.find((item) => item.id === id) || null)}
+ leading={
+
+ }
+ minMenuWidth={220}
+ emptyLabel={t('assistant.composer.knowledgeEmpty')}
+ triggerClassName={toolbarChipClass(Boolean(selectedKnowledge))}
+ trailing={
+ selectedKnowledge ? (
+ 1
+ ) : null
+ }
+ />
+
+ )
+}
diff --git a/src/features/assistant/input/SkillSelector.tsx b/src/features/assistant/input/SkillSelector.tsx
new file mode 100644
index 0000000..602e5e3
--- /dev/null
+++ b/src/features/assistant/input/SkillSelector.tsx
@@ -0,0 +1,463 @@
+import { ArrowUpLeft, Check, ChevronDown, LayoutGrid, Search, Sparkles } from 'lucide-react'
+import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
+import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { Link, useLocation, useNavigate } from 'react-router-dom'
+import { t } from '../../../i18n'
+import { selectMenuPanelClass } from '../../../components/ui/SelectMenu'
+import { ConfirmDialog } from '../../../components/ui/ConfirmDialog'
+import type { ResourceOption } from '../types/chat'
+import { ToolbarChipIcon, toolbarChipClass } from './toolbarChip'
+import type { SkillCategoryId } from '../../skills/catalog'
+import { CATEGORY_ICON, CATEGORY_TONE, skillIcon } from '../../skills/icons'
+import {
+ categoriesWithCounts,
+ matchesSkillQuery,
+ skillsForPicker,
+ type PresentedSkill,
+} from '../../skills/present'
+import { skillNeedsSetup } from '../../settings/types'
+import { skillSettingsPath } from '../../settings/api'
+
+type MenuPos = {
+ top: number
+ left: number
+ width: number
+ openUp: boolean
+ maxHeight: number
+}
+
+export function SkillSelector({
+ skills,
+ value,
+ onChange,
+}: {
+ skills: ResourceOption[]
+ value: ResourceOption | null
+ onChange: (skill: ResourceOption | null) => void
+}) {
+ const listId = useId()
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState('')
+ const [categoryId, setCategoryId] = useState('all')
+ const [pos, setPos] = useState(null)
+ const triggerRef = useRef(null)
+ const menuRef = useRef(null)
+ const searchRef = useRef(null)
+ const navigate = useNavigate()
+ const location = useLocation()
+ const [pending, setPending] = useState(null)
+
+ const presented = useMemo(() => skillsForPicker(skills), [skills])
+ const selected = presented.find((item) => item.id === value?.id) || null
+ const categories = useMemo(() => categoriesWithCounts(presented), [presented])
+ const visible = useMemo(() => {
+ return presented.filter((skill) => {
+ if (categoryId !== 'all' && skill.categoryId !== categoryId) return false
+ return matchesSkillQuery(skill, query)
+ })
+ }, [presented, categoryId, query])
+ const grouped = useMemo(() => {
+ if (categoryId !== 'all') {
+ const category = categories.find((item) => item.id === categoryId)
+ return [
+ {
+ id: categoryId,
+ title: category?.title || '',
+ blurb: category?.blurb || '',
+ items: visible,
+ },
+ ]
+ }
+ return categories
+ .map((category) => ({
+ ...category,
+ items: visible.filter((skill) => skill.categoryId === category.id),
+ }))
+ .filter((group) => group.items.length > 0)
+ }, [categories, categoryId, visible])
+
+ function updatePosition() {
+ if (!triggerRef.current) return
+ const rect = triggerRef.current.getBoundingClientRect()
+ const width = Math.min(780, Math.max(360, window.innerWidth - 24))
+ const spaceBelow = window.innerHeight - rect.bottom - 12
+ const spaceAbove = rect.top - 12
+ const openUp = spaceBelow < 300 && spaceAbove > spaceBelow
+ const maxHeight = Math.min(560, Math.max(300, openUp ? spaceAbove : spaceBelow))
+ const left = Math.min(Math.max(12, rect.right - width), window.innerWidth - width - 12)
+ setPos({
+ top: openUp ? rect.top - 6 : rect.bottom + 6,
+ left,
+ width,
+ openUp,
+ maxHeight,
+ })
+ }
+
+ useLayoutEffect(() => {
+ if (!open) return
+ updatePosition()
+ setQuery('')
+ setCategoryId('all')
+ }, [open])
+
+ useEffect(() => {
+ if (!open) return
+ function onPointerDown(event: MouseEvent) {
+ const target = event.target as Node
+ if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) {
+ setOpen(false)
+ }
+ }
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ setOpen(false)
+ triggerRef.current?.focus()
+ }
+ }
+ function onReposition() {
+ updatePosition()
+ }
+ document.addEventListener('mousedown', onPointerDown)
+ document.addEventListener('keydown', onKeyDown)
+ window.addEventListener('resize', onReposition)
+ window.addEventListener('scroll', onReposition, true)
+ return () => {
+ document.removeEventListener('mousedown', onPointerDown)
+ document.removeEventListener('keydown', onKeyDown)
+ window.removeEventListener('resize', onReposition)
+ window.removeEventListener('scroll', onReposition, true)
+ }
+ }, [open])
+
+ useEffect(() => {
+ if (!open) return
+ searchRef.current?.focus()
+ }, [open, pos])
+
+ function selectSkill(next: PresentedSkill | null) {
+ if (!next) {
+ onChange(null)
+ setOpen(false)
+ triggerRef.current?.focus()
+ return
+ }
+ const original = skills.find((item) => item.id === next.id) || {
+ id: next.id,
+ name: next.name,
+ description: next.description,
+ nameEn: next.nameEn,
+ category: next.category,
+ categoryId: next.categoryId,
+ domain: next.domain,
+ icon: next.icon,
+ }
+ if (skillNeedsSetup(original)) {
+ setPending(original)
+ setOpen(false)
+ return
+ }
+ onChange(original)
+ setOpen(false)
+ triggerRef.current?.focus()
+ }
+
+ function onTriggerKeyDown(event: ReactKeyboardEvent) {
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ setOpen(true)
+ }
+ }
+
+ const SelectedIcon = skillIcon(selected?.icon)
+ const triggerLabel = selected?.name || t('assistant.composer.skill')
+
+ const menu =
+ open && pos
+ ? createPortal(
+
+
+
+
+
+
+
+
+
مهارتهای کامیدسو
+
+ {presented.length} مهارت در {categories.length} حوزه تخصصی
+
+
+
+
setOpen(false)}
+ className="inline-flex shrink-0 items-center gap-1 rounded-full border border-line bg-white px-2.5 py-1 text-[11px] font-medium text-ink-muted transition hover:border-accent/30 hover:text-accent"
+ >
+ کاتالوگ کامل
+
+
+
+
+
+ setQuery(event.target.value)}
+ placeholder="جستوجوی مهارت، حوزه یا مسئله…"
+ className="h-10 w-full rounded-xl border border-line bg-white pr-9 pl-3 text-sm text-ink outline-none transition placeholder:text-ink-subtle focus:border-accent focus:ring-2 focus:ring-accent/15"
+ />
+
+
+
+
+
+
+
+ setCategoryId('all')} />
+ {categories.map((category) => (
+ setCategoryId(category.id)}
+ />
+ ))}
+
+
+ {visible.length === 0 ? (
+
+
+
{t('assistant.composer.skillEmpty')}
+
+ ) : (
+
+ {grouped.map((group) => {
+ const tone =
+ group.id !== 'all' && group.id in CATEGORY_TONE
+ ? CATEGORY_TONE[group.id as SkillCategoryId]
+ : CATEGORY_TONE.decision
+ return (
+
+
+
+
{group.title}
+ {group.items.length}
+
+ {categoryId !== 'all' && group.blurb ? (
+ {group.blurb}
+ ) : null}
+
+ {group.items.map((skill) => (
+ selectSkill(skill)}
+ />
+ ))}
+
+
+ )
+ })}
+
+ )}
+
+
+
+
,
+ document.body,
+ )
+ : null
+
+ return (
+
+
+ {menu}
+ {
+ if (!pending) return
+ const returnTo = `${location.pathname}${location.search}`
+ navigate(skillSettingsPath(pending.id, returnTo || '/assistant'))
+ setPending(null)
+ }}
+ onCancel={() => {
+ setPending(null)
+ triggerRef.current?.focus()
+ }}
+ />
+
+ )
+}
+
+function CategoryButton({
+ active,
+ label,
+ count,
+ tone,
+ onClick,
+}: {
+ active: boolean
+ label: string
+ count: number
+ tone?: SkillCategoryId
+ onClick: () => void
+}) {
+ const palette = tone ? CATEGORY_TONE[tone] : null
+ const Icon = tone ? CATEGORY_ICON[tone] : LayoutGrid
+ return (
+
+ )
+}
+
+function MiniChip({
+ active,
+ label,
+ tone,
+ onClick,
+}: {
+ active: boolean
+ label: string
+ tone?: SkillCategoryId
+ onClick: () => void
+}) {
+ const palette = tone ? CATEGORY_TONE[tone] : null
+ return (
+
+ )
+}
+
+function SkillPickCard({
+ skill,
+ active,
+ onSelect,
+}: {
+ skill: PresentedSkill
+ active: boolean
+ onSelect: () => void
+}) {
+ const Icon = skillIcon(skill.icon)
+ const tone = skill.categoryId !== 'other' ? CATEGORY_TONE[skill.categoryId] : CATEGORY_TONE.decision
+ return (
+
+ )
+}
diff --git a/src/features/assistant/input/ToolsBar.test.tsx b/src/features/assistant/input/ToolsBar.test.tsx
new file mode 100644
index 0000000..a41caec
--- /dev/null
+++ b/src/features/assistant/input/ToolsBar.test.tsx
@@ -0,0 +1,122 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+import { ToolsBar } from './ToolsBar'
+
+describe('ToolsBar', () => {
+ it('shows labeled resource chips and hides the model switcher', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'داده' })).toHaveTextContent('داده')
+ expect(screen.queryByRole('button', { name: 'فایلها' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'پایگاه داده' })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'دانش' })).toHaveTextContent('دانش')
+ expect(screen.getByRole('button', { name: 'مهارت' })).toHaveTextContent('مهارت')
+ expect(screen.queryByRole('button', { name: 'مدل' })).not.toBeInTheDocument()
+ expect(screen.queryByText('خودکار')).not.toBeInTheDocument()
+ })
+
+ it('keeps the skill picker when files are attached instead of locking data analysis', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'مهارت' })).toBeInTheDocument()
+ expect(screen.queryByText('تحلیل داده')).not.toBeInTheDocument()
+ })
+
+ it('lets the user pick Fast, Medium, or Deep for the next request', async () => {
+ const user = userEvent.setup()
+ const onReasoningModeChange = vi.fn()
+ render(
+
+
+ ,
+ )
+
+ const depth = screen.getByRole('button', { name: 'سریع' }).parentElement
+ expect(depth).toHaveClass('ms-auto')
+ expect(screen.getByRole('button', { name: 'سریع' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'متوسط' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'عمیق' })).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'متوسط' }))
+ expect(onReasoningModeChange).toHaveBeenCalledWith('medium')
+ await user.click(screen.getByRole('button', { name: 'عمیق' }))
+ expect(onReasoningModeChange).toHaveBeenCalledWith('deep')
+ })
+
+ it('toggles report mode for the next request', async () => {
+ const user = userEvent.setup()
+ const onReportModeChange = vi.fn()
+ render(
+
+
+ ,
+ )
+
+ const toggle = screen.getByRole('button', { name: 'گزارش' })
+ expect(toggle).toHaveAttribute('aria-pressed', 'false')
+ await user.click(toggle)
+ expect(onReportModeChange).toHaveBeenCalledWith(true)
+ })
+})
diff --git a/src/features/assistant/input/ToolsBar.tsx b/src/features/assistant/input/ToolsBar.tsx
new file mode 100644
index 0000000..55d2f72
--- /dev/null
+++ b/src/features/assistant/input/ToolsBar.tsx
@@ -0,0 +1,102 @@
+import { FileBarChart } from 'lucide-react'
+import { ResourcePicker } from './ResourcePicker'
+import { SkillSelector } from './SkillSelector'
+import { ToolbarChipIcon, toolbarChipClass } from './toolbarChip'
+import { t } from '../../../i18n'
+import type { ResourceOption } from '../types/chat'
+import type { KamidsoFile } from '../session-files'
+
+export function ToolsBar({
+ databases,
+ knowledge,
+ skills,
+ selectedDbs,
+ selectedKnowledge,
+ selectedSkill,
+ onSelectDbs,
+ onSelectKnowledge,
+ onSelectSkill,
+ reportMode = false,
+ onReportModeChange,
+ reasoningMode = 'fast',
+ onReasoningModeChange,
+ libraryFiles = [],
+ attachedFileIds = [],
+ onToggleLibraryFile,
+ onUploadNew,
+}: {
+ databases: ResourceOption[]
+ knowledge: ResourceOption[]
+ skills: ResourceOption[]
+ selectedDbs: ResourceOption[]
+ selectedKnowledge: ResourceOption | null
+ selectedSkill: ResourceOption | null
+ onSelectDbs: (items: ResourceOption[]) => void
+ onSelectKnowledge: (item: ResourceOption | null) => void
+ onSelectSkill: (item: ResourceOption | null) => void
+ reportMode?: boolean
+ onReportModeChange?: (enabled: boolean) => void
+ reasoningMode?: 'fast' | 'medium' | 'deep'
+ onReasoningModeChange?: (mode: 'fast' | 'medium' | 'deep') => void
+ libraryFiles?: KamidsoFile[]
+ attachedFileIds?: readonly string[]
+ onToggleLibraryFile?: (file: KamidsoFile) => void
+ onUploadNew?: () => void
+}) {
+ return (
+
+
+
+ {onReportModeChange ? (
+
+ ) : null}
+ {onReasoningModeChange ? (
+
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/input/toolbarChip.tsx b/src/features/assistant/input/toolbarChip.tsx
new file mode 100644
index 0000000..0a9e966
--- /dev/null
+++ b/src/features/assistant/input/toolbarChip.tsx
@@ -0,0 +1,31 @@
+import type { LucideIcon } from 'lucide-react'
+
+const TONE = {
+ muted: 'bg-white text-ink-muted ring-1 ring-inset ring-line/80',
+ database: 'bg-success-soft text-success',
+ files: 'bg-accent-soft text-accent',
+ knowledge: 'bg-warn-soft text-warn',
+ skill: 'bg-info-soft text-info',
+} as const
+
+export function ToolbarChipIcon({
+ icon: Icon,
+ tone = 'muted',
+}: {
+ icon: LucideIcon
+ tone?: keyof typeof TONE
+}) {
+ return (
+
+
+
+ )
+}
+
+export function toolbarChipClass(active: boolean) {
+ return active
+ ? 'shrink-0 max-w-[16rem] border-accent/25 bg-white text-ink shadow-[0_1px_2px_rgba(23,32,51,0.05)] hover:border-accent/40'
+ : 'shrink-0 border-line/80 bg-surface-subtle/90 text-ink-muted hover:border-accent/30 hover:bg-white hover:text-ink'
+}
diff --git a/src/features/assistant/lib/events.ts b/src/features/assistant/lib/events.ts
new file mode 100644
index 0000000..45cbef6
--- /dev/null
+++ b/src/features/assistant/lib/events.ts
@@ -0,0 +1,8 @@
+export function unwrapWorkspaceEvent>(payload: T): T {
+ if (payload && typeof payload === 'object' && payload.workspace && typeof payload.workspace === 'object') {
+ const { workspace, ...canonical } = payload
+ // Kamidso canonical fields (e.g. localized title) must win over raw DBGPT workspace copies.
+ return { ...(workspace as Record), ...canonical } as T
+ }
+ return payload
+}
diff --git a/src/features/assistant/lib/final.test.ts b/src/features/assistant/lib/final.test.ts
new file mode 100644
index 0000000..5a5cd05
--- /dev/null
+++ b/src/features/assistant/lib/final.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest'
+import { cleanFinalContent, extractTerminateResult, looksTruncated } from './final'
+
+describe('cleanFinalContent', () => {
+ it('unwraps single-line ReAct terminate envelopes', () => {
+ const raw =
+ 'Thought: تمام دادههای لازم جمع شد. Action: terminate Action Input: {"result": "## تحلیل فروش\\n\\nجزئیات"}'
+ expect(cleanFinalContent(raw)).toBe('## تحلیل فروش\n\nجزئیات')
+ })
+
+ it('leaves ordinary markdown alone', () => {
+ expect(cleanFinalContent('## خلاصه\n\nمتن عادی')).toBe('## خلاصه\n\nمتن عادی')
+ })
+
+ it('extractTerminateResult handles truncated JSON', () => {
+ expect(extractTerminateResult('Action Input: {"result": "## شروع\\nادامه')).toBe('## شروع\nادامه')
+ })
+})
+
+describe('looksTruncated', () => {
+ it('flags a cover page that dies on heading 1', () => {
+ const text = [
+ 'گزارش ریسک ریزش مشتریان (Churn Risk) — پلتفرم معاملات طلا و نقره',
+ 'تاریخ تحلیل: سهشنبه ۱۴ مهر ۱۴۰۵ | دادهها: users، transactions',
+ '۱. خ',
+ ].join('\n')
+ expect(looksTruncated(text)).toBe(true)
+ })
+
+ it('does not flag a finished answer that ends with a later bullet', () => {
+ expect(
+ looksTruncated(
+ 'کاربران در معرض ریزش ۱۰ نفر هستند.\n\n۵. اقدام فوری: تماس با ده کاربر برتر تا پایان هفته.',
+ ),
+ ).toBe(false)
+ })
+})
diff --git a/src/features/assistant/lib/final.ts b/src/features/assistant/lib/final.ts
new file mode 100644
index 0000000..8509196
--- /dev/null
+++ b/src/features/assistant/lib/final.ts
@@ -0,0 +1,99 @@
+export type AgentCitation = {
+ index: number
+ id: string
+ sourceName: string
+ excerpt: string
+}
+
+export function decodeFinalEvent(payload: Record): { content: string; citations: AgentCitation[] } {
+ const content =
+ (typeof payload.content === 'string' && payload.content) ||
+ (typeof payload.answer === 'string' && payload.answer) ||
+ (typeof payload.text === 'string' && payload.text) ||
+ ''
+ const rawCitations = Array.isArray(payload.citations) ? payload.citations : []
+ const citations: AgentCitation[] = rawCitations.map((item, index) => {
+ const c = item as Record
+ return {
+ index: typeof c.index === 'number' ? c.index : index + 1,
+ id: String(c.id ?? index + 1),
+ sourceName: String(c.source_name ?? c.sourceName ?? 'منبع'),
+ excerpt: String(c.excerpt ?? c.content ?? ''),
+ }
+ })
+ return { content, citations }
+}
+
+/** Decode JSON string escapes inside a terminate Action Input result. */
+function unescapeJsonString(raw: string): string {
+ let out = ''
+ for (let i = 0; i < raw.length; i += 1) {
+ const ch = raw[i]
+ if (ch === '\\' && i + 1 < raw.length) {
+ const nxt = raw[i + 1]
+ const map: Record = {
+ n: '\n',
+ t: '\t',
+ r: '\r',
+ '"': '"',
+ '\\': '\\',
+ '/': '/',
+ }
+ out += map[nxt] ?? nxt
+ i += 1
+ continue
+ }
+ out += ch
+ }
+ return out
+}
+
+/**
+ * Extract user-facing answer from a leaked ReAct terminate envelope.
+ * Backend should already unwrap this; this is a UI safety net.
+ */
+export function extractTerminateResult(text: string): string | null {
+ if (!text) return null
+ const match = /"(?:result|output)"\s*:\s*"/.exec(text)
+ if (!match || match.index == null) return null
+ const start = match.index + match[0].length
+ let end = start
+ while (end < text.length) {
+ if (text[end] === '\\' && end + 1 < text.length) {
+ end += 2
+ continue
+ }
+ if (text[end] === '"') break
+ end += 1
+ }
+ const body = text.slice(start, end)
+ return body.trim() ? unescapeJsonString(body) : null
+}
+
+/** True when the visible answer looks cut off mid-section (token/JSON cap). */
+export function looksTruncated(text: string): boolean {
+ const trimmed = text.trim()
+ if (trimmed.length < 80) return false
+ const heading1 = trimmed.match(/(?:^|\n)\s*(?:1|۱)[\.\-–:]\s*(\S.*)?$/)
+ if (heading1 && (heading1[1] || '').trim().length < 40) return true
+ if (/(?:^|\n)#{1,6}\s+\S.{0,80}$/.test(trimmed) && trimmed.split('\n').filter(Boolean).length >= 3) {
+ return true
+ }
+ return false
+}
+
+export function cleanFinalContent(text: string): string {
+ let cleaned = text.replace(/[\s\S]*?<\/references>/gi, '').trim()
+ if (
+ /(?:^|\s)Thought\s*:/i.test(cleaned) ||
+ /(?:^|\s)Action\s*Input\s*:/i.test(cleaned) ||
+ /(?:^|\s)Action\s*:\s*terminate\b/i.test(cleaned)
+ ) {
+ const salvaged = extractTerminateResult(cleaned)
+ if (salvaged) return salvaged.trim()
+ cleaned = cleaned
+ .replace(/(?:^|\n)\s*(?:Thought|Action|Action Input|Observation|Phase)\s*:\s*/gi, '\n')
+ .trim()
+ }
+ return cleaned
+}
diff --git a/src/features/assistant/questions/QuestionDock.tsx b/src/features/assistant/questions/QuestionDock.tsx
new file mode 100644
index 0000000..f9df035
--- /dev/null
+++ b/src/features/assistant/questions/QuestionDock.tsx
@@ -0,0 +1,83 @@
+import { useEffect, useState } from 'react'
+import { Button } from '../../../components/Button'
+import type { PendingQuestion } from '../../../contracts/run'
+import { t } from '../../../i18n'
+
+export function QuestionDock({
+ question,
+ onReply,
+ onReject,
+}: {
+ question: PendingQuestion
+ onReply: (text: string) => void
+ onReject: () => void
+}) {
+ const hasOptions = Boolean(question.options?.length)
+ const [text, setText] = useState('')
+ const [showOther, setShowOther] = useState(!hasOptions)
+
+ useEffect(() => {
+ setText('')
+ setShowOther(!question.options?.length)
+ }, [question.id, question.options?.length])
+
+ const submitText = () => {
+ const value = text.trim()
+ if (!value) return
+ onReply(value)
+ }
+
+ return (
+
+
{t('assistant.question.title')}
+
{question.question}
+
+ {hasOptions ? (
+
+ {question.options!.map((opt) => (
+
+ ))}
+ {!showOther ? (
+
+ ) : null}
+
+ ) : null}
+
+ {showOther ? (
+
+
setText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ submitText()
+ }
+ }}
+ className="w-full rounded-lg border border-line px-3 py-2 text-sm"
+ placeholder={t('assistant.question.placeholder')}
+ />
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
diff --git a/src/features/assistant/report/downloadReportPdf.test.ts b/src/features/assistant/report/downloadReportPdf.test.ts
new file mode 100644
index 0000000..9c2f49b
--- /dev/null
+++ b/src/features/assistant/report/downloadReportPdf.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+import { reportTitleFromHtml, withPrintStyles } from './downloadReportPdf'
+
+describe('withPrintStyles', () => {
+ it('keeps the report and adds a print stylesheet plus title', () => {
+ const html = "گزارش
"
+ const prepared = withPrintStyles(html, 'فروش')
+ expect(prepared).toContain('گزارش
')
+ expect(prepared).toContain('data-kamidso-print')
+ expect(prepared).toContain('فروش')
+ expect(prepared).toContain("dir='rtl'")
+ })
+
+ it('does not replace an existing title', () => {
+ const html = 'اصلی'
+ const prepared = withPrintStyles(html, 'دیگر')
+ expect(prepared).toContain('اصلی')
+ expect(prepared).not.toContain('دیگر')
+ })
+
+ it('reads the document title', () => {
+ expect(reportTitleFromHtml('فروش ماه', 'گزارش')).toBe(
+ 'فروش ماه',
+ )
+ expect(reportTitleFromHtml('بدون عنوان
', 'گزارش')).toBe('گزارش')
+ })
+})
diff --git a/src/features/assistant/report/downloadReportPdf.ts b/src/features/assistant/report/downloadReportPdf.ts
new file mode 100644
index 0000000..ebf22ab
--- /dev/null
+++ b/src/features/assistant/report/downloadReportPdf.ts
@@ -0,0 +1,85 @@
+const PRINT_STYLE = ``
+
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+}
+
+export function reportTitleFromHtml(html: string, fallback: string): string {
+ const match = html.match(/]*>([\s\S]*?)<\/title>/i)
+ const text = match?.[1]?.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
+ return text || fallback
+}
+
+/** Keep the report document and add only print color + a filename title. */
+export function withPrintStyles(html: string, title: string): string {
+ const safeTitle = escapeHtml(title.trim() || 'گزارش')
+ let doc = html.trim()
+ if (!doc) {
+ return `${safeTitle}${PRINT_STYLE}`
+ }
+ if (!/]/i.test(doc)) {
+ if (/]/i.test(doc)) {
+ doc = doc.replace(/]*)>/i, `${safeTitle}`)
+ } else if (/]/i.test(doc)) {
+ doc = doc.replace(/]*)>/i, `${safeTitle}`)
+ } else {
+ doc = `${safeTitle}${doc}`
+ }
+ }
+ if (/<\/head>/i.test(doc)) {
+ return doc.replace(/<\/head>/i, `${PRINT_STYLE}`)
+ }
+ return `${PRINT_STYLE}${doc}`
+}
+
+function cleanupLater(iframe: HTMLIFrameElement, url: string) {
+ window.setTimeout(() => {
+ iframe.remove()
+ URL.revokeObjectURL(url)
+ }, 60_000)
+}
+
+/**
+ * Open the browser print dialog for this exact HTML document.
+ * Save as PDF there — the page is the report, without the app chrome.
+ */
+export function downloadReportPdf(html: string, title: string) {
+ const prepared = withPrintStyles(html, title)
+ const blob = new Blob([prepared], { type: 'text/html;charset=utf-8' })
+ const url = URL.createObjectURL(blob)
+ const iframe = document.createElement('iframe')
+ iframe.setAttribute('aria-hidden', 'true')
+ iframe.title = 'report-pdf'
+ iframe.style.cssText =
+ 'position:fixed;left:0;top:0;width:960px;height:1280px;opacity:0;pointer-events:none;border:0;z-index:-1'
+ iframe.src = url
+ iframe.onload = () => {
+ const win = iframe.contentWindow
+ try {
+ win?.focus()
+ win?.print()
+ } catch {
+ iframe.remove()
+ URL.revokeObjectURL(url)
+ return
+ }
+ try {
+ win?.addEventListener('afterprint', () => {
+ iframe.remove()
+ URL.revokeObjectURL(url)
+ })
+ } catch {
+ cleanupLater(iframe, url)
+ return
+ }
+ cleanupLater(iframe, url)
+ }
+ document.body.appendChild(iframe)
+}
diff --git a/src/features/assistant/report/extractReport.test.ts b/src/features/assistant/report/extractReport.test.ts
new file mode 100644
index 0000000..62f706a
--- /dev/null
+++ b/src/features/assistant/report/extractReport.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import {
+ extractHtmlFromArtifacts,
+ extractHtmlFromOutputList,
+ resolveTurnHtml,
+} from './extractReport'
+
+const sampleHtml = "گزارش
"
+
+describe('extractHtml', () => {
+ it('finds html output in step outputs', () => {
+ const found = extractHtmlFromOutputList([
+ { output_type: 'text', content: 'ok' },
+ { output_type: 'html', content: sampleHtml },
+ ])
+ expect(found).toBe(sampleHtml)
+ })
+
+ it('finds html artifact', () => {
+ const found = extractHtmlFromArtifacts([
+ { id: '1', type: 'html', name: 'گزارش', content: sampleHtml },
+ ])
+ expect(found).toContain('گزارش')
+ })
+
+ it('prefers htmlByViewId', () => {
+ const resolved = resolveTurnHtml({
+ viewId: 'view-1',
+ htmlByViewId: { 'view-1': sampleHtml },
+ })
+ expect(resolved).toBe(sampleHtml)
+ })
+})
diff --git a/src/features/assistant/report/extractReport.ts b/src/features/assistant/report/extractReport.ts
new file mode 100644
index 0000000..99ee471
--- /dev/null
+++ b/src/features/assistant/report/extractReport.ts
@@ -0,0 +1,65 @@
+import type { KamidsoArtifact } from '../../../contracts/run'
+import type { ExecutionOutput, ExecutionState } from '../types/chat'
+
+function asHtml(content: unknown): string | null {
+ if (typeof content === 'string' && content.trim()) return content
+ if (content && typeof content === 'object' && 'html' in (content as object)) {
+ const html = String((content as { html?: unknown }).html ?? '')
+ return html.trim() ? html : null
+ }
+ return null
+}
+
+export function extractHtmlFromOutputs(outputs: Record): string | null {
+ for (const stepId of Object.keys(outputs)) {
+ const found = extractHtmlFromOutputList(outputs[stepId])
+ if (found) return found
+ }
+ return null
+}
+
+export function extractHtmlFromOutputList(outputs?: ExecutionOutput[]): string | null {
+ if (!outputs?.length) return null
+ for (let i = outputs.length - 1; i >= 0; i -= 1) {
+ const item = outputs[i]
+ if (item.output_type === 'html') {
+ const html = asHtml(item.content)
+ if (html) return html
+ }
+ }
+ return null
+}
+
+export function extractHtmlFromExecution(execution?: ExecutionState): string | null {
+ if (!execution) return null
+ return extractHtmlFromOutputs(execution.outputs)
+}
+
+export function extractHtmlFromArtifacts(artifacts?: KamidsoArtifact[]): string | null {
+ if (!artifacts?.length) return null
+ for (let i = artifacts.length - 1; i >= 0; i -= 1) {
+ const artifact = artifacts[i]
+ if (artifact.type === 'html') {
+ const html = asHtml(artifact.content)
+ if (html) return html
+ }
+ }
+ return null
+}
+
+export function resolveTurnHtml({
+ execution,
+ artifacts,
+ htmlByViewId,
+ viewId,
+}: {
+ execution?: ExecutionState
+ artifacts?: KamidsoArtifact[]
+ htmlByViewId?: Record
+ viewId?: string
+}): string | null {
+ if (viewId && htmlByViewId?.[viewId]) {
+ return htmlByViewId[viewId]
+ }
+ return extractHtmlFromExecution(execution) ?? extractHtmlFromArtifacts(artifacts)
+}
diff --git a/src/features/assistant/report/formatValue.test.ts b/src/features/assistant/report/formatValue.test.ts
new file mode 100644
index 0000000..3b263bb
--- /dev/null
+++ b/src/features/assistant/report/formatValue.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { displayLabel, formatCellValue, isNumericColumn, parseNumeric, splitValueAndUnit } from './formatValue'
+
+describe('report value formatting', () => {
+ it('parses grouped and plain numbers', () => {
+ expect(parseNumeric(4484808201)).toBe(4484808201)
+ expect(parseNumeric('4,484,808,201')).toBe(4484808201)
+ expect(parseNumeric('۴٬۴۸۴٬۸۰۸٬۲۰۱')).toBe(4484808201)
+ expect(parseNumeric('789ردیف')).toBe(789)
+ expect(parseNumeric('1404/01/01 – 1404/12/26')).toBeNull()
+ })
+
+ it('keeps a gap between amount and unit', () => {
+ expect(splitValueAndUnit('4484808201', 'ریال')).toEqual({
+ amount: '4,484,808,201',
+ unit: 'ریال',
+ })
+ expect(splitValueAndUnit('4,484,808,201ریال')).toEqual({
+ amount: '4,484,808,201',
+ unit: 'ریال',
+ })
+ })
+
+ it('formats table cells and Jalali month labels', () => {
+ expect(formatCellValue(580900500)).toBe('580,900,500')
+ expect(displayLabel('01', 'ماه')).toBe('فروردین')
+ expect(displayLabel('12', 'ماه')).toBe('اسفند')
+ expect(isNumericColumn('ماه', ['01', '02'])).toBe(false)
+ expect(isNumericColumn('مقدار (ریال)', [580900500, 298033700])).toBe(true)
+ })
+})
diff --git a/src/features/assistant/report/formatValue.ts b/src/features/assistant/report/formatValue.ts
new file mode 100644
index 0000000..68bc45a
--- /dev/null
+++ b/src/features/assistant/report/formatValue.ts
@@ -0,0 +1,94 @@
+const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
+const LATIN_DIGITS = '0123456789'
+const JALALI_MONTHS = [
+ 'فروردین',
+ 'اردیبهشت',
+ 'خرداد',
+ 'تیر',
+ 'مرداد',
+ 'شهریور',
+ 'مهر',
+ 'آبان',
+ 'آذر',
+ 'دی',
+ 'بهمن',
+ 'اسفند',
+]
+
+export function toLatinDigits(value: string): string {
+ return value.replace(/[۰-۹]/g, (digit) => LATIN_DIGITS[PERSIAN_DIGITS.indexOf(digit)] ?? digit)
+}
+
+export function parseNumeric(value: unknown): number | null {
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
+ if (typeof value !== 'string') return null
+ let text = toLatinDigits(value).trim()
+ if (!text || text === '—' || text === '-') return null
+ text = text.replace(/[٬،]/g, ',').replace(/\s/g, '')
+ const match = text.match(/^([+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?)(.*)$/)
+ if (!match) return null
+ if (/\d/.test(match[2])) return null
+ const parsed = Number(match[1].replace(/,/g, ''))
+ return Number.isFinite(parsed) ? parsed : null
+}
+
+export function formatNumber(value: number): string {
+ const abs = Math.abs(value)
+ const fractionDigits = Number.isInteger(value) || abs >= 100 ? 0 : abs >= 1 ? 2 : 4
+ return new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: 0,
+ }).format(value)
+}
+
+export function formatCellValue(value: unknown): string {
+ if (value == null || (typeof value === 'number' && Number.isNaN(value))) return '—'
+ if (typeof value === 'object') return JSON.stringify(value)
+ const numeric = parseNumeric(value)
+ if (numeric == null) return String(value)
+ const formatted = formatNumber(numeric)
+ if (typeof value === 'string') {
+ const latin = toLatinDigits(value).trim()
+ const suffix = latin.replace(/^[+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?/, '').trim()
+ if (suffix && !/^[,\s]+$/.test(suffix)) return `${formatted} ${suffix}`
+ }
+ return formatted
+}
+
+export function splitValueAndUnit(value: unknown, unit?: string): { amount: string; unit?: string } {
+ if (value == null || value === '') return { amount: '—', unit }
+ const numeric = parseNumeric(value)
+ if (numeric == null) {
+ return { amount: String(value), unit: unit?.trim() || undefined }
+ }
+ let extra: string | undefined
+ if (typeof value === 'string') {
+ const latin = toLatinDigits(value).trim()
+ extra = latin.replace(/^[+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?/, '').trim() || undefined
+ }
+ const resolved = (unit?.trim() || extra || '').trim() || undefined
+ return { amount: formatNumber(numeric), unit: resolved }
+}
+
+export function jalaliMonthLabel(value: unknown, columnTitle?: string): string | null {
+ const title = String(columnTitle || '')
+ if (!/ماه|month/i.test(title)) return null
+ const numeric = parseNumeric(value)
+ if (numeric == null || numeric < 1 || numeric > 12 || !Number.isInteger(numeric)) return null
+ return JALALI_MONTHS[numeric - 1] ?? null
+}
+
+export function displayLabel(value: unknown, columnTitle?: string): string {
+ return jalaliMonthLabel(value, columnTitle) || formatCellValue(value)
+}
+
+export function isMostlyNumeric(values: unknown[]): boolean {
+ if (!values.length) return false
+ const hits = values.filter((value) => parseNumeric(value) != null).length
+ return hits / values.length >= 0.6
+}
+
+export function isNumericColumn(title: string, values: unknown[]): boolean {
+ if (/ماه|month|نام|شرح|جهت|عنوان|label|category|نوع|وضعیت/i.test(title)) return false
+ return isMostlyNumeric(values)
+}
diff --git a/src/features/assistant/session-files/AttachmentMessageCards.tsx b/src/features/assistant/session-files/AttachmentMessageCards.tsx
new file mode 100644
index 0000000..0d2da07
--- /dev/null
+++ b/src/features/assistant/session-files/AttachmentMessageCards.tsx
@@ -0,0 +1,61 @@
+import { FileSpreadsheet, FileText, Image as ImageIcon } from 'lucide-react'
+import type { SessionFileSnapshot } from './types'
+
+function fileIcon(name: string) {
+ const ext = name.split('.').pop()?.toLowerCase() ?? ''
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return ImageIcon
+ if (['csv', 'xlsx', 'xls'].includes(ext)) return FileSpreadsheet
+ return FileText
+}
+
+function fileTone(name: string) {
+ const ext = name.split('.').pop()?.toLowerCase() ?? ''
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) {
+ return 'bg-violet-50 text-violet-600'
+ }
+ if (['csv', 'xlsx', 'xls'].includes(ext)) {
+ return 'bg-emerald-50 text-emerald-700'
+ }
+ return 'bg-slate-100 text-slate-600'
+}
+
+function formatBytes(bytes: number) {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+export default function AttachmentMessageCards({
+ files,
+ onPreview,
+}: {
+ files: readonly SessionFileSnapshot[]
+ onPreview?: (file: SessionFileSnapshot) => void
+}) {
+ if (!files.length) return null
+ return (
+
+ {files.map((file) => {
+ const Icon = fileIcon(file.name)
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/features/assistant/session-files/AttachmentMessageGroup.tsx b/src/features/assistant/session-files/AttachmentMessageGroup.tsx
new file mode 100644
index 0000000..2c1d70f
--- /dev/null
+++ b/src/features/assistant/session-files/AttachmentMessageGroup.tsx
@@ -0,0 +1,12 @@
+import AttachmentMessageCards from './AttachmentMessageCards'
+import type { SessionFileSnapshot } from './types'
+
+export default function AttachmentMessageGroup({
+ files,
+ onPreview,
+}: {
+ files: readonly SessionFileSnapshot[]
+ onPreview?: (file: SessionFileSnapshot) => void
+}) {
+ return
+}
diff --git a/src/features/assistant/session-files/AttachmentPreview.tsx b/src/features/assistant/session-files/AttachmentPreview.tsx
new file mode 100644
index 0000000..cf4c816
--- /dev/null
+++ b/src/features/assistant/session-files/AttachmentPreview.tsx
@@ -0,0 +1,90 @@
+import { Loader2, X } from 'lucide-react'
+import type { SessionFilePreviewSnapshot } from './types'
+
+export function AttachmentPreviewCloseButton({ onClick }: { onClick: () => void }) {
+ return (
+
+ )
+}
+
+export function AttachmentPreviewPanelTitle({ name }: { name: string }) {
+ return {name}
+}
+
+export default function AttachmentPreview({
+ snapshot,
+ loading,
+ error,
+}: {
+ snapshot: SessionFilePreviewSnapshot | null
+ loading?: boolean
+ error?: string | null
+}) {
+ if (loading) {
+ return (
+
+
+ در حال بارگذاری پیشنمایش…
+
+ )
+ }
+ if (error) {
+ return {error}
+ }
+ if (!snapshot) return null
+
+ const preview = snapshot.preview || {}
+ const kind = snapshot.kind || preview.kind
+
+ if (kind === 'table' && Array.isArray(preview.columns) && Array.isArray(preview.rows)) {
+ const columns = preview.columns as string[]
+ const rows = preview.rows as unknown[][]
+ return (
+
+
+
+
+ {columns.map((col) => (
+ |
+ {col}
+ |
+ ))}
+
+
+
+ {rows.slice(0, 100).map((row, i) => (
+
+ {columns.map((_, j) => (
+ |
+ {String(row[j] ?? '')}
+ |
+ ))}
+
+ ))}
+
+
+ {rows.length > 100 ? (
+
نمایش ۱۰۰ سطر اول از {rows.length}
+ ) : null}
+
+ )
+ }
+
+ const text =
+ (typeof preview.text === 'string' && preview.text) ||
+ (typeof preview.content === 'string' && preview.content) ||
+ JSON.stringify(preview, null, 2)
+
+ return (
+
+ {text}
+
+ )
+}
diff --git a/src/features/assistant/session-files/AttachmentRail.test.tsx b/src/features/assistant/session-files/AttachmentRail.test.tsx
new file mode 100644
index 0000000..5d017da
--- /dev/null
+++ b/src/features/assistant/session-files/AttachmentRail.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import AttachmentRail from './AttachmentRail'
+
+describe('AttachmentRail', () => {
+ it('renders selected databases and knowledge next to files', () => {
+ const onRemoveDatabase = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.getByText('SQLite')).toBeInTheDocument()
+ expect(screen.getByText('طلابان')).toBeInTheDocument()
+ expect(screen.getByText('نیتریمو')).toBeInTheDocument()
+ expect(screen.getByText('فضای دانش')).toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/session-files/AttachmentRail.tsx b/src/features/assistant/session-files/AttachmentRail.tsx
new file mode 100644
index 0000000..84d627e
--- /dev/null
+++ b/src/features/assistant/session-files/AttachmentRail.tsx
@@ -0,0 +1,169 @@
+import { AlertCircle, BookOpen, CheckCircle2, Database, Loader2, Paperclip, RefreshCw, X } from 'lucide-react'
+import type { ReactNode } from 'react'
+import type { DraftFile, LegacyServerFile } from './types'
+import { formatBytes, toLegacyRailItem, toRailItems } from './attachment-view-model'
+import { t } from '../../../i18n'
+
+export type RailDatabase = { id: string; name: string; type?: string }
+export type RailKnowledge = { id: string; name: string }
+
+export interface AttachmentRailProps {
+ drafts: readonly DraftFile[]
+ legacyFiles?: readonly LegacyServerFile[]
+ databases?: readonly RailDatabase[]
+ knowledge?: RailKnowledge | null
+ onRemove?: (clientId: string) => void
+ onRetry?: (clientId: string) => void
+ onRemoveDatabase?: (id: string) => void
+ onRemoveKnowledge?: () => void
+ addControl?: ReactNode
+ className?: string
+}
+
+export function AttachmentRailAddButton({ onClick }: { onClick: () => void }) {
+ return (
+
+ )
+}
+
+export default function AttachmentRail({
+ drafts,
+ legacyFiles = [],
+ databases = [],
+ knowledge = null,
+ onRemove,
+ onRetry,
+ onRemoveDatabase,
+ onRemoveKnowledge,
+ addControl,
+ className = '',
+}: AttachmentRailProps) {
+ const draftItems = toRailItems(drafts)
+ const legacyItems = legacyFiles.map(toLegacyRailItem)
+ const hasKnowledge = Boolean(knowledge?.name)
+ if (
+ draftItems.length === 0 &&
+ legacyItems.length === 0 &&
+ databases.length === 0 &&
+ !hasKnowledge &&
+ !addControl
+ ) {
+ return null
+ }
+
+ return (
+
+ {databases.map((item) => (
+
+
+
+
+
+
{item.name}
+
{item.type || t('assistant.composer.database')}
+
+ {onRemoveDatabase ? (
+
+ ) : null}
+
+ ))}
+ {hasKnowledge ? (
+
+
+
+
+
+
{knowledge!.name}
+
{t('assistant.panel.knowledgeHint')}
+
+ {onRemoveKnowledge ? (
+
+ ) : null}
+
+ ) : null}
+ {legacyItems.map((item) => (
+
+
+
+
+
+
{item.name}
+
{formatBytes(item.size)} · سرور
+
+
+ ))}
+ {draftItems.map((item) => {
+ const failed = item.uploadStatus === 'failed' || item.uploadStatus === 'blocked'
+ const uploading = item.uploadStatus === 'uploading' || item.uploadStatus === 'queued'
+ const done = item.uploadStatus === 'done'
+ return (
+
+
+ {uploading ? (
+
+ ) : failed ? (
+
+ ) : done ? (
+
+ ) : (
+
+ )}
+
+
+
{item.name}
+
+ {item.size ? formatBytes(item.size) : ''}
+ {uploading ? ` · ${Math.round(item.uploadProgress * 100)}%` : ''}
+
+
+ {failed && onRetry ? (
+
+ ) : null}
+ {onRemove ? (
+
+ ) : null}
+
+ )
+ })}
+ {addControl}
+
+ )
+}
diff --git a/src/features/assistant/session-files/FileTitleModal.tsx b/src/features/assistant/session-files/FileTitleModal.tsx
new file mode 100644
index 0000000..f143a73
--- /dev/null
+++ b/src/features/assistant/session-files/FileTitleModal.tsx
@@ -0,0 +1,71 @@
+import { useEffect, useState } from 'react'
+import { Modal } from '../../../components/Modal'
+import { Button } from '../../../components/Button'
+import { Field, Input } from '../../../components/ui/Field'
+import { defaultFileTitle } from './fileKind'
+
+export function FileTitleModal({
+ files,
+ onClose,
+ onConfirm,
+}: {
+ files: File[] | null
+ onClose: () => void
+ onConfirm: (files: File[], titles: string[]) => void
+}) {
+ const open = Boolean(files?.length)
+ const [titles, setTitles] = useState([])
+
+ useEffect(() => {
+ setTitles((files ?? []).map((file) => defaultFileTitle(file.name)))
+ }, [files])
+
+ if (!open || !files) return null
+
+ function confirm() {
+ const next = titles.map((title, index) => title.trim() || defaultFileTitle(files![index]!.name))
+ onConfirm(files!, next)
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/src/features/assistant/session-files/api.ts b/src/features/assistant/session-files/api.ts
new file mode 100644
index 0000000..f04379b
--- /dev/null
+++ b/src/features/assistant/session-files/api.ts
@@ -0,0 +1,151 @@
+import { apiFetch, kamidsoUrl } from '../../../services/api'
+import type {
+ CapabilitiesResult,
+ KamidsoFile,
+ SessionFilePreviewSnapshot,
+ SessionFileSnapshot,
+ SessionFilesApi,
+ UploadCapabilities,
+} from './types'
+import { DEFAULT_UPLOAD_CAPABILITIES, kamidsoFileToSnapshot } from './types'
+
+export class SessionFilesApiError extends Error {
+ readonly errCode: string | null
+ readonly statusCode?: number
+
+ constructor(message: string, errCode: string | null = null, statusCode?: number) {
+ super(message)
+ this.name = 'SessionFilesApiError'
+ this.errCode = errCode
+ this.statusCode = statusCode
+ }
+}
+
+export async function uploadFiles(params: {
+ sessionId: string
+ files: readonly File[]
+ title?: string
+ signal?: AbortSignal
+ onProgress?: (ratio: number) => void
+ timeoutMs?: number
+}): Promise {
+ const controller = new AbortController()
+ const timeout = window.setTimeout(() => controller.abort(), params.timeoutMs ?? 180000)
+ if (params.signal) {
+ params.signal.addEventListener('abort', () => controller.abort())
+ }
+
+ const results: SessionFileSnapshot[] = []
+ try {
+ for (const file of params.files) {
+ const form = new FormData()
+ form.append('file', file, file.name)
+ if (params.title?.trim()) form.append('title', params.title.trim())
+ const response = await fetch(kamidsoUrl('/files'), {
+ method: 'POST',
+ body: form,
+ credentials: 'include',
+ signal: controller.signal,
+ })
+ if (!response.ok) {
+ const data = await response.json().catch(() => null)
+ throw new SessionFilesApiError(
+ (data as { message?: string })?.message ?? 'Upload failed',
+ (data as { code?: string })?.code ?? null,
+ response.status,
+ )
+ }
+ const payload = (await response.json()) as { file: KamidsoFile }
+ results.push(kamidsoFileToSnapshot(payload.file))
+ params.onProgress?.(results.length / params.files.length)
+ }
+ return results
+ } finally {
+ window.clearTimeout(timeout)
+ }
+}
+
+export async function listFiles(_sessionId: string): Promise {
+ const files = await listWorkspaceFiles()
+ return files.map(kamidsoFileToSnapshot)
+}
+
+function asFileList(payload: unknown): KamidsoFile[] {
+ if (Array.isArray(payload)) return payload as KamidsoFile[]
+ if (payload && typeof payload === 'object') {
+ const obj = payload as { files?: unknown; data?: unknown }
+ if (Array.isArray(obj.files)) return obj.files as KamidsoFile[]
+ if (Array.isArray(obj.data)) return obj.data as KamidsoFile[]
+ if (obj.data && typeof obj.data === 'object' && Array.isArray((obj.data as { files?: unknown }).files)) {
+ return (obj.data as { files: KamidsoFile[] }).files
+ }
+ }
+ return []
+}
+
+export async function listWorkspaceFiles(): Promise {
+ const payload = await apiFetch('/files')
+ return asFileList(payload)
+}
+
+export async function uploadWorkspaceFile(file: File, title?: string): Promise {
+ const form = new FormData()
+ form.append('file', file, file.name)
+ if (title?.trim()) form.append('title', title.trim())
+ const payload = await apiFetch<{ file: KamidsoFile }>('/files', { method: 'POST', body: form })
+ return payload.file
+}
+
+export async function updateWorkspaceFile(fileId: string, title: string): Promise {
+ const payload = await apiFetch<{ file: KamidsoFile }>(`/files/${encodeURIComponent(fileId)}`, {
+ method: 'PATCH',
+ body: JSON.stringify({ title }),
+ })
+ return payload.file
+}
+
+export async function deleteFile(_sessionId: string, fileId: string): Promise {
+ await apiFetch(`/files/${encodeURIComponent(fileId)}`, { method: 'DELETE' })
+}
+
+export async function fetchCapabilities(): Promise {
+ try {
+ const payload = await apiFetch<{
+ max_file_size: number
+ supported_extensions: string[]
+ }>('/files/capabilities')
+ const capabilities: UploadCapabilities = {
+ ...DEFAULT_UPLOAD_CAPABILITIES,
+ max_file_bytes: payload.max_file_size,
+ supported_extensions: payload.supported_extensions,
+ }
+ return { capabilities, source: 'server' }
+ } catch (error) {
+ return { capabilities: DEFAULT_UPLOAD_CAPABILITIES, source: 'fallback', error }
+ }
+}
+
+export async function previewFile(
+ _sessionId: string,
+ fileId: string,
+): Promise {
+ const preview = await apiFetch>(`/files/${encodeURIComponent(fileId)}/preview`)
+ return {
+ file_id: fileId,
+ name: String(preview.name ?? fileId),
+ media_type: String(preview.mime_type ?? ''),
+ kind: String(preview.type ?? 'file'),
+ status: 'ready',
+ truncated: true,
+ preview,
+ error_code: null,
+ }
+}
+
+export const sessionFilesApi: SessionFilesApi = {
+ uploadFiles,
+ listFiles,
+ deleteFile,
+ fetchCapabilities,
+ previewFile,
+}
diff --git a/src/features/assistant/session-files/attachment-view-model.ts b/src/features/assistant/session-files/attachment-view-model.ts
new file mode 100644
index 0000000..a010705
--- /dev/null
+++ b/src/features/assistant/session-files/attachment-view-model.ts
@@ -0,0 +1,535 @@
+/**
+ * Pure view-model helpers for the session-files UI components.
+ *
+ * This module intentionally stays DOM/JSX-free: every helper that the rail,
+ * the history card group and the preview renderer depend on is derived here
+ * so it can be verified with `node --test` under Node's type stripping
+ * (AttachmentRail.test.ts). The .tsx components only wire these results into
+ * Ant Design JSX.
+ */
+
+import type { DraftFile, LegacyServerFile, PreviewPhase, SessionFileStatus, UploadPhase } from './types';
+
+// ---------------------------------------------------------------------------
+// Rail item view model
+// ---------------------------------------------------------------------------
+
+/** Flattened, render-ready view of one DraftFile row in the rail. */
+export interface RailItem {
+ clientId: string;
+ name: string;
+ size: number;
+ mediaType: string;
+ uploadStatus: UploadPhase;
+ /** 0..1 */
+ uploadProgress: number;
+ previewStatus: PreviewPhase;
+ /** Hard failure reason (upload or validation), if any. */
+ error: string | null;
+ /** Server file id once the upload completed. */
+ fileId: string | null;
+}
+
+export function toRailItems(drafts: readonly DraftFile[]): RailItem[] {
+ return drafts.map(draft => ({
+ clientId: draft.clientId,
+ name: draft.title || draft.snapshot?.name || draft.identity.name,
+ size: draft.identity.size,
+ mediaType: draft.file?.type || draft.snapshot?.media_type || '',
+ uploadStatus: draft.upload.status,
+ uploadProgress: draft.upload.progress,
+ previewStatus: draft.preview.status,
+ error: draft.validation.status === 'invalid' ? draft.validation.error : draft.upload.error,
+ fileId: draft.snapshot?.file_id ?? null,
+ }));
+}
+
+/**
+ * Read-only rail entry for a legacy server-preloaded file (example cards).
+ * It claims no upload state, retry, or preview: the card renders name, size
+ * and a server badge only, and the `legacy:` key is non-resolvable.
+ */
+export interface LegacyRailItem {
+ key: string;
+ name: string;
+ size: number;
+ mediaType: string;
+}
+
+export function toLegacyRailItem(file: LegacyServerFile): LegacyRailItem {
+ return {
+ key: `legacy:${file.file_path}`,
+ name: file.name,
+ size: file.size,
+ mediaType: file.media_type,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Status labels (upload and preview stay orthogonal)
+// ---------------------------------------------------------------------------
+
+export const UPLOAD_STATUS_LABELS: Record = Object.freeze({
+ queued: 'Queued',
+ uploading: 'Uploading',
+ done: 'Ready',
+ failed: 'Upload failed',
+ cancelled: 'Cancelled',
+ blocked: 'Blocked',
+});
+
+export const PREVIEW_STATUS_LABELS: Record = Object.freeze({
+ idle: '',
+ loading: 'Loading preview',
+ ready: 'Preview ready',
+ preview_failed: 'Preview unavailable',
+});
+
+/** Hard failures (upload/validation) render destructive and demand attention. */
+export function isHardFailure(item: Pick): boolean {
+ return item.uploadStatus === 'failed' || item.uploadStatus === 'blocked';
+}
+
+/** Only real upload failures can be retried; validation-blocked items cannot. */
+export function canRetry(item: Pick): boolean {
+ return item.uploadStatus === 'failed';
+}
+
+// ---------------------------------------------------------------------------
+// Aggregate progress (deterministic aria-valuenow source)
+// ---------------------------------------------------------------------------
+
+function clamp01(value: number): number {
+ return Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0));
+}
+
+/**
+ * Mean upload progress across in-flight items (queued → 0, uploading → its
+ * ratio, done → 1). Failed/cancelled/blocked items are excluded; 0 when the
+ * queue is idle so the bar can hide without flickering.
+ */
+export function aggregateUploadProgress(items: readonly Pick[]): number {
+ let total = 0;
+ let count = 0;
+ for (const item of items) {
+ if (item.uploadStatus === 'done') {
+ total += 1;
+ count += 1;
+ } else if (item.uploadStatus === 'uploading' || item.uploadStatus === 'queued') {
+ total += clamp01(item.uploadProgress);
+ count += 1;
+ }
+ }
+ return count === 0 ? 0 : total / count;
+}
+
+/** Integer percent for `aria-valuenow`; deterministic for screen readers. */
+export function progressPercent(ratio: number): number {
+ return Math.round(clamp01(ratio) * 100);
+}
+
+// ---------------------------------------------------------------------------
+// Accessibility names
+// ---------------------------------------------------------------------------
+
+export function removeAriaLabel(name: string): string {
+ return `Remove ${name}`;
+}
+
+export function retryAriaLabel(name: string): string {
+ return `Retry upload for ${name}`;
+}
+
+// ---------------------------------------------------------------------------
+// First rows + "+N" collapse
+// ---------------------------------------------------------------------------
+
+/** Rows (one per item) shown before the remainder collapses into `+N`. */
+export const RAIL_MAX_VISIBLE = 6;
+
+export function collapseRail(
+ items: readonly T[],
+ maxVisible = RAIL_MAX_VISIBLE,
+): { visible: readonly T[]; hiddenCount: number } {
+ if (items.length <= maxVisible) {
+ return { visible: items, hiddenCount: 0 };
+ }
+ return { visible: items.slice(0, maxVisible), hiddenCount: items.length - maxVisible };
+}
+
+/**
+ * A single removable draft already communicates its own metadata and remove
+ * action. Multi-file and legacy sets keep the summary for count/total and the
+ * legacy-only clear affordance.
+ */
+export function shouldShowComfortableSummary(input: {
+ totalCount: number;
+ legacyCount: number;
+ hasPerItemRemove: boolean;
+}): boolean {
+ if (input.totalCount <= 0) return false;
+ return input.totalCount > 1 || input.legacyCount > 0 || !input.hasPerItemRemove;
+}
+
+// ---------------------------------------------------------------------------
+// Compact composer rail
+// ---------------------------------------------------------------------------
+
+/**
+ * Compact mode reserves room for the overflow manager and add-file control.
+ * These breakpoints intentionally use the rail's own measured width rather
+ * than the viewport: the inference panel may be narrow on a desktop screen.
+ */
+export function resolveCompactRailLimit(containerWidth: number): 0 | 1 | 2 | 3 {
+ const width = Number.isFinite(containerWidth) ? Math.max(0, containerWidth) : 0;
+ if (width < 280) return 0;
+ if (width < 320) return 1;
+ if (width < 560) return 2;
+ return 3;
+}
+
+export interface CompactRailLayout {
+ /** Most recently added entries kept visible beside the add control. */
+ visible: readonly T[];
+ /** Older entries represented by the +N manager. */
+ hidden: readonly T[];
+ hiddenCount: number;
+}
+
+/**
+ * Keep the newest entries visible so adding a file always gives immediate
+ * visual confirmation. Hidden entries retain their original ordering.
+ */
+export function collapseCompactRail(items: readonly T[], containerWidth: number): CompactRailLayout {
+ const limit = resolveCompactRailLimit(containerWidth);
+ const hiddenCount = Math.max(0, items.length - limit);
+ return {
+ visible: items.slice(hiddenCount),
+ hidden: items.slice(0, hiddenCount),
+ hiddenCount,
+ };
+}
+
+export interface CompactRailStatusSummary {
+ errorCount: number;
+ processingCount: number;
+ warningCount: number;
+}
+
+/** Status signal displayed on +N so folded failures never disappear. */
+export function summarizeCompactRailStatus(
+ items: readonly Pick[],
+): CompactRailStatusSummary {
+ return items.reduce(
+ (summary, item) => {
+ if (isHardFailure(item)) summary.errorCount += 1;
+ else if (
+ item.uploadStatus === 'queued' ||
+ item.uploadStatus === 'uploading' ||
+ (item.uploadStatus === 'done' && item.previewStatus === 'loading')
+ ) {
+ summary.processingCount += 1;
+ } else if (item.previewStatus === 'preview_failed') {
+ summary.warningCount += 1;
+ }
+ return summary;
+ },
+ { errorCount: 0, processingCount: 0, warningCount: 0 },
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Preview eligibility
+// ---------------------------------------------------------------------------
+
+/** Drafts: only a completed upload with a settled preview may be previewed. */
+export function canPreviewItem(item: Pick): boolean {
+ return item.uploadStatus === 'done' && (item.previewStatus === 'ready' || item.previewStatus === 'preview_failed');
+}
+
+/** Snapshots (history cards): same rule on the server lifecycle status. */
+export function canPreviewSnapshot(status: SessionFileStatus): boolean {
+ return status === 'ready' || status === 'preview_failed';
+}
+
+// ---------------------------------------------------------------------------
+// Overlay breakpoint selection
+// ---------------------------------------------------------------------------
+
+export const PREVIEW_DESKTOP_MIN_WIDTH = 1024;
+export const PREVIEW_FULLSCREEN_MAX_WIDTH = 640;
+export const PREVIEW_DRAWER_MAX_WIDTH = 720;
+export const PREVIEW_DRAWER_VIEWPORT_RATIO = 0.9;
+
+export type PreviewOverlayLayout = { mode: 'right-panel' } | { mode: 'drawer'; width: number } | { mode: 'fullscreen' };
+
+/**
+ * Desktop (≥1024px) reuses the chat right panel; tablet (640–1023px) opens an
+ * overlay Drawer of `min(720px, 90vw)`; phones (<640px) go fullscreen so the
+ * chat column never gets squeezed.
+ */
+export function resolvePreviewOverlay(viewportWidth: number): PreviewOverlayLayout {
+ if (viewportWidth >= PREVIEW_DESKTOP_MIN_WIDTH) return { mode: 'right-panel' };
+ if (viewportWidth >= PREVIEW_FULLSCREEN_MAX_WIDTH) {
+ return {
+ mode: 'drawer',
+ width: Math.min(PREVIEW_DRAWER_MAX_WIDTH, Math.round(viewportWidth * PREVIEW_DRAWER_VIEWPORT_RATIO)),
+ };
+ }
+ return { mode: 'fullscreen' };
+}
+
+// ---------------------------------------------------------------------------
+// Reduced motion
+// ---------------------------------------------------------------------------
+
+/** Applied to the rail root; CSS under this class disables animations. */
+export const REDUCED_MOTION_CLASS = 'session-files-reduced-motion';
+
+export function motionClassName(prefersReducedMotion: boolean): string {
+ return prefersReducedMotion ? REDUCED_MOTION_CLASS : '';
+}
+
+// ---------------------------------------------------------------------------
+// Formatting + icon selection
+// ---------------------------------------------------------------------------
+
+export function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes)) return '0 B';
+ const KB = 1024;
+ const MB = KB * 1024;
+ const GB = MB * 1024;
+ if (bytes < KB) return `${bytes} B`;
+ if (bytes < MB) return `${(bytes / KB).toFixed(2)} KB`;
+ if (bytes < GB) return `${(bytes / MB).toFixed(2)} MB`;
+ return `${(bytes / GB).toFixed(2)} GB`;
+}
+
+export type FileIconKey = 'table' | 'image' | 'slide' | 'text' | 'file';
+
+const TABLE_EXTS = new Set(['csv', 'tsv', 'xls', 'xlsx', 'parquet', 'json', 'jsonl']);
+const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp']);
+const SLIDE_EXTS = new Set(['ppt', 'pptx']);
+const TEXT_EXTS = new Set(['txt', 'md', 'markdown', 'log', 'doc', 'docx', 'rtf', 'pdf']);
+
+export function fileIconKey(input: { name?: string; mediaType?: string; kind?: string }): FileIconKey {
+ const mediaType = (input.mediaType ?? '').toLowerCase();
+ if (
+ input.kind === 'table' ||
+ mediaType.includes('spreadsheet') ||
+ mediaType.includes('excel') ||
+ mediaType.includes('csv')
+ ) {
+ return 'table';
+ }
+ if (mediaType.startsWith('image/')) return 'image';
+ if (mediaType.includes('presentation')) return 'slide';
+ const ext = (input.name ?? '').toLowerCase().split('.').pop() ?? '';
+ if (TABLE_EXTS.has(ext)) return 'table';
+ if (IMAGE_EXTS.has(ext)) return 'image';
+ if (SLIDE_EXTS.has(ext)) return 'slide';
+ if (
+ TEXT_EXTS.has(ext) ||
+ mediaType.startsWith('text/') ||
+ mediaType.includes('pdf') ||
+ mediaType.includes('word') ||
+ mediaType.includes('markdown')
+ ) {
+ return 'text';
+ }
+ return 'file';
+}
+
+// ---------------------------------------------------------------------------
+// Preview payload normalization (AttachmentPreview)
+// ---------------------------------------------------------------------------
+
+export type PreviewMode = 'table' | 'text' | 'document' | 'empty';
+
+/**
+ * User-facing scope shown beside the previewed file metadata. The view-model
+ * stays i18n-agnostic: it returns translation keys (+ interpolation params)
+ * and the React layer renders them through `t()` — same pattern as
+ * `typeLabelKey` in AttachmentMessageCards.
+ */
+export interface PreviewScopeSummary {
+ labelKey:
+ | 'session_files_scope_limited'
+ | 'session_files_scope_table_empty'
+ | 'session_files_scope_table_rows'
+ | 'session_files_scope_document'
+ | 'session_files_scope_content';
+ labelParams?: { count: number };
+ partial: boolean;
+ hintKey: 'session_files_scope_truncated_hint' | null;
+}
+
+/**
+ * Describe what is visibly rendered without pretending `truncated` means a
+ * row-only limit. The backend may truncate rows, columns, sheets, pages or
+ * bytes, so table row counts always come from the normalized visible rows.
+ */
+export function buildPreviewScopeSummary(input: {
+ mode: PreviewMode;
+ truncated: boolean;
+ visibleRows?: number;
+}): PreviewScopeSummary | null {
+ const hintKey: PreviewScopeSummary['hintKey'] = input.truncated ? 'session_files_scope_truncated_hint' : null;
+
+ if (input.mode === 'empty') {
+ return input.truncated
+ ? {
+ labelKey: 'session_files_scope_limited',
+ partial: true,
+ hintKey,
+ }
+ : null;
+ }
+
+ if (input.mode === 'table') {
+ const rows = Number.isFinite(input.visibleRows) ? Math.max(0, Math.floor(input.visibleRows ?? 0)) : 0;
+ return rows === 0
+ ? {
+ labelKey: 'session_files_scope_table_empty',
+ partial: input.truncated,
+ hintKey,
+ }
+ : {
+ labelKey: 'session_files_scope_table_rows',
+ labelParams: { count: rows },
+ partial: input.truncated,
+ hintKey,
+ };
+ }
+
+ return {
+ labelKey: input.mode === 'document' ? 'session_files_scope_document' : 'session_files_scope_content',
+ partial: input.truncated,
+ hintKey,
+ };
+}
+
+export interface TablePreviewData {
+ columns: string[];
+ rows: unknown[][];
+}
+
+export interface DocumentPreviewData {
+ metadata: Record;
+ text: string | null;
+}
+
+function isPlainRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/** Columns + rows arrays (or record rows) aligned to the column order. */
+/** Header cells may be numbers/null (xlsx); table columns must be strings. */
+const headerCellText = (cell: unknown): string => {
+ if (cell === null || cell === undefined) return '';
+ return String(cell);
+};
+
+export function normalizeTablePreview(preview: Record | null | undefined): TablePreviewData | null {
+ if (!isPlainRecord(preview)) return null;
+
+ // Shape A — explicit columns (JSON/JSONL inspector output). Rows may be
+ // arrays or records keyed by column name.
+ const columns = preview.columns;
+ if (Array.isArray(columns) && !columns.some(column => typeof column !== 'string')) {
+ const typedColumns = columns as string[];
+ const rows = preview.rows;
+ if (!Array.isArray(rows)) return null;
+ const normalizedRows: unknown[][] = rows.map(row => {
+ if (Array.isArray(row)) {
+ return typedColumns.map((_, index) => row[index]);
+ }
+ if (isPlainRecord(row)) {
+ return typedColumns.map(column => row[column]);
+ }
+ return typedColumns.map(() => undefined);
+ });
+ return { columns: typedColumns, rows: normalizedRows };
+ }
+
+ // Shape B — raw delimited rows (CSV/TSV inspector output: no `columns`,
+ // the first row is the header, mirroring spreadsheet conventions).
+ const rawRows = preview.rows;
+ if (Array.isArray(rawRows) && rawRows.length > 0 && Array.isArray(rawRows[0])) {
+ const header = (rawRows[0] as unknown[]).map(headerCellText);
+ const rows = rawRows
+ .slice(1)
+ .map(row => (Array.isArray(row) ? header.map((_, index) => row[index]) : header.map(() => undefined)));
+ return { columns: header, rows };
+ }
+
+ // Shape C — workbook sheets (XLSX/XLS inspector output). The first sheet
+ // represents the table; its first row is the header.
+ const sheets = preview.sheets;
+ if (Array.isArray(sheets) && sheets.length > 0) {
+ const first = sheets[0];
+ if (isPlainRecord(first) && Array.isArray(first.rows) && first.rows.length > 0 && Array.isArray(first.rows[0])) {
+ const header = (first.rows[0] as unknown[]).map(headerCellText);
+ const rows = first.rows
+ .slice(1)
+ .map(row => (Array.isArray(row) ? header.map((_, index) => row[index]) : header.map(() => undefined)));
+ return { columns: header, rows };
+ }
+ }
+
+ return null;
+}
+
+/** Plain text body of a text/markdown preview (`text` or `content`). */
+export function normalizeTextPreview(preview: Record | null | undefined): string | null {
+ if (!isPlainRecord(preview)) return null;
+ if (typeof preview.text === 'string') return preview.text;
+ if (typeof preview.content === 'string') return preview.content;
+ return null;
+}
+
+/** Document metadata plus optional extracted body text. */
+export function normalizeDocumentPreview(
+ preview: Record | null | undefined,
+): DocumentPreviewData | null {
+ if (!isPlainRecord(preview)) return null;
+ const metadata =
+ isPlainRecord(preview.metadata) && Object.keys(preview.metadata).length > 0 ? preview.metadata : null;
+ const text =
+ typeof preview.text === 'string' ? preview.text : typeof preview.content === 'string' ? preview.content : null;
+ if (!metadata && text === null) return null;
+ return { metadata: metadata ?? {}, text };
+}
+
+/**
+ * Route a preview payload to a render mode. Empty/missing payloads never
+ * pretend to be renderable — the component shows a graceful empty state.
+ */
+export function resolvePreviewMode(input: {
+ kind?: string;
+ mediaType?: string;
+ preview?: Record | null;
+}): PreviewMode {
+ const preview = input.preview;
+ if (!isPlainRecord(preview) || Object.keys(preview).length === 0) return 'empty';
+
+ const kind = input.kind ?? '';
+ const media = (input.mediaType ?? '').toLowerCase();
+
+ const isTableLike =
+ kind === 'table' || media.includes('csv') || media.includes('spreadsheet') || media.includes('excel');
+ if (isTableLike) return normalizeTablePreview(preview) ? 'table' : 'empty';
+
+ const isDocumentLike =
+ kind === 'document' || media.includes('pdf') || media.includes('word') || media.includes('presentation');
+ if (isDocumentLike) return normalizeDocumentPreview(preview) ? 'document' : 'empty';
+
+ const isTextLike = kind === 'text' || kind === 'markdown' || media.startsWith('text/');
+ if (isTextLike) return normalizeTextPreview(preview) !== null ? 'text' : 'empty';
+
+ // Unknown kind: opportunistically match the payload shape.
+ if (normalizeTablePreview(preview)) return 'table';
+ if (normalizeTextPreview(preview) !== null) return 'text';
+ if (normalizeDocumentPreview(preview)) return 'document';
+ return 'empty';
+}
diff --git a/src/features/assistant/session-files/fileKind.test.ts b/src/features/assistant/session-files/fileKind.test.ts
new file mode 100644
index 0000000..86d1625
--- /dev/null
+++ b/src/features/assistant/session-files/fileKind.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+import {
+ defaultFileTitle,
+ fileKindLabel,
+ fileKindLine,
+ isDataFileKind,
+ isDataFilename,
+ isKnowledgeFileKind,
+ isKnowledgeFilename,
+} from './fileKind'
+
+describe('fileKind', () => {
+ it('builds a display title from the filename stem', () => {
+ expect(defaultFileTitle('Book1 (1).xlsx')).toBe('Book1 (1)')
+ expect(defaultFileTitle('C:\\\\reports\\\\sales.csv')).toBe('sales')
+ })
+
+ it('writes tabular kinds as data files and documents as knowledge', () => {
+ expect(fileKindLine('excel')).toBe('فایل داده · Excel')
+ expect(fileKindLine('csv')).toBe('فایل داده · CSV')
+ expect(fileKindLine('file')).toBe('فایل')
+ expect(fileKindLine('pdf')).toBe('سند · PDF')
+ expect(fileKindLabel('pdf')).toBe('PDF')
+ })
+
+ it('splits data files from knowledge documents', () => {
+ expect(isDataFileKind('excel')).toBe(true)
+ expect(isDataFileKind('spreadsheet')).toBe(true)
+ expect(isDataFileKind('pdf')).toBe(false)
+ expect(isKnowledgeFileKind('pdf')).toBe(true)
+ expect(isKnowledgeFileKind('csv')).toBe(false)
+ expect(isDataFilename('sales.xlsx')).toBe(true)
+ expect(isDataFilename('policy.docx')).toBe(false)
+ expect(isKnowledgeFilename('policy.pdf')).toBe(true)
+ })
+})
diff --git a/src/features/assistant/session-files/fileKind.ts b/src/features/assistant/session-files/fileKind.ts
new file mode 100644
index 0000000..52b59b2
--- /dev/null
+++ b/src/features/assistant/session-files/fileKind.ts
@@ -0,0 +1,78 @@
+const KIND_LABELS: Record = {
+ excel: 'Excel',
+ csv: 'CSV',
+ json: 'JSON',
+ parquet: 'Parquet',
+ table: 'Excel',
+ spreadsheet: 'Excel',
+ pdf: 'PDF',
+ document: 'سند',
+ presentation: 'ارائه',
+ text: 'متن',
+ file: 'فایل',
+}
+
+const EXT_TO_KIND: Record = {
+ '.xlsx': 'excel',
+ '.xls': 'excel',
+ '.csv': 'csv',
+ '.tsv': 'csv',
+ '.json': 'json',
+ '.jsonl': 'json',
+ '.parquet': 'parquet',
+ '.pdf': 'pdf',
+ '.doc': 'document',
+ '.docx': 'document',
+ '.ppt': 'presentation',
+ '.pptx': 'presentation',
+ '.md': 'text',
+ '.markdown': 'text',
+ '.txt': 'text',
+}
+
+export const DATA_FILE_KINDS = new Set(['excel', 'csv', 'json', 'parquet', 'table', 'spreadsheet'])
+export const KNOWLEDGE_FILE_KINDS = new Set(['pdf', 'document', 'presentation', 'text'])
+
+export const DATA_FILE_ACCEPT = '.csv,.tsv,.xls,.xlsx,.json,.jsonl,.parquet'
+export const KNOWLEDGE_FILE_ACCEPT = '.pdf,.doc,.docx,.ppt,.pptx,.txt,.md,.markdown'
+
+export function kindFromFilename(name: string): string {
+ const ext = name.toLowerCase().match(/\.[^.]+$/)?.[0] || ''
+ return EXT_TO_KIND[ext] || 'file'
+}
+
+export function isDataFileKind(kind?: string | null): boolean {
+ return DATA_FILE_KINDS.has((kind || '').toLowerCase())
+}
+
+export function isKnowledgeFileKind(kind?: string | null): boolean {
+ return KNOWLEDGE_FILE_KINDS.has((kind || '').toLowerCase())
+}
+
+export function isDataFilename(name: string): boolean {
+ return isDataFileKind(kindFromFilename(name))
+}
+
+export function isKnowledgeFilename(name: string): boolean {
+ return isKnowledgeFileKind(kindFromFilename(name))
+}
+
+export function fileKindLabel(kind?: string | null): string {
+ const key = (kind || 'file').toLowerCase()
+ return KIND_LABELS[key] || kind || 'فایل'
+}
+
+/** Product line on Databases / data picker: "فایل داده · Excel". Documents: "سند · PDF". */
+export function fileKindLine(kind?: string | null): string {
+ const key = (kind || 'file').toLowerCase()
+ if (!key || key === 'file') return 'فایل'
+ if (isDataFileKind(key)) return `فایل داده · ${fileKindLabel(key)}`
+ if (isKnowledgeFileKind(key)) return `سند · ${fileKindLabel(key)}`
+ return `فایل · ${fileKindLabel(key)}`
+}
+
+export function defaultFileTitle(filename: string): string {
+ const base = filename.replace(/^.*[/\\]/, '').trim()
+ const stem = base.replace(/\.[^.]+$/, '').trim()
+ return (stem || base || 'file').slice(0, 120)
+}
diff --git a/src/features/assistant/session-files/home-adapter.ts b/src/features/assistant/session-files/home-adapter.ts
new file mode 100644
index 0000000..0438bd3
--- /dev/null
+++ b/src/features/assistant/session-files/home-adapter.ts
@@ -0,0 +1,221 @@
+/**
+ * Home-page adapter for the session-files module.
+ *
+ * Pure, network-free bridge between the chat wire payloads and the module's
+ * UI contract:
+ *
+ * - history payload v2 `input_files` → frozen `SessionFileSnapshot[]` (one
+ * entry per file attached to a turn). Owner-side history carries the real
+ * `file_id`; public share pages and frozen scheduled-task snapshots carry
+ * the non-resolvable `display_key` instead, and this module never invents a
+ * usable server id;
+ * - legacy v1 history carries no file snapshot: parsing a v1 payload returns
+ * an empty list, so those messages render without attachment cards;
+ * - legacy example files (preloaded on the server via Kamidso admin examples API)
+ * are staged as frozen `LegacyServerFile`s on the send snapshot and resent
+ * verbatim through the legacy `file_path` protocol. `snapshotFromLegacyFile`
+ * renders them display-only and `extInfoForSend` refuses to mix them with
+ * session `file_ids`;
+ * - the send-path ext_info builder is regression-safe: with zero files it
+ * reproduces the legacy file_path / plain-text payload byte-for-byte (no
+ * `file_ids`/`session_id` keys appear).
+ */
+
+import type { LegacyServerFile, SessionFileSnapshot, SessionFileStatus, SessionFilesSendSnapshot } from './types';
+
+/** History payload version carrying the public `input_files` snapshot. */
+export const REACT_HISTORY_PAYLOAD_VERSION = 2;
+
+const SESSION_FILE_STATUSES: readonly SessionFileStatus[] = [
+ 'uploading',
+ 'inspecting',
+ 'ready',
+ 'preview_failed',
+ 'failed',
+ 'deleted',
+];
+
+function toSnapshot(entry: unknown, index: number): SessionFileSnapshot | null {
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return null;
+ const wire = entry as Record;
+
+ const name = wire.name;
+ if (typeof name !== 'string' || !name) return null;
+
+ // Owner history carries the real file_id; share-scrubbed and frozen-task
+ // entries carry the non-resolvable display_key. When both are missing a
+ // synthesised ordinal key keeps rendering deterministic without ever
+ // resembling a usable server id.
+ const rawId = wire.file_id ?? wire.display_key;
+ const fileId = typeof rawId === 'string' && rawId ? rawId : `anonymous-attachment-${index + 1}`;
+
+ const size = typeof wire.size === 'number' && Number.isFinite(wire.size) && wire.size >= 0 ? wire.size : 0;
+ const status =
+ typeof wire.status === 'string' && (SESSION_FILE_STATUSES as readonly string[]).includes(wire.status)
+ ? (wire.status as SessionFileStatus)
+ : 'ready';
+ return Object.freeze({
+ file_id: fileId,
+ name,
+ size,
+ media_type: typeof wire.media_type === 'string' ? wire.media_type : '',
+ kind: typeof wire.kind === 'string' ? wire.kind : '',
+ status,
+ ordinal: Number.isInteger(wire.ordinal) ? (wire.ordinal as number) : index,
+ error_code: typeof wire.error_code === 'string' ? wire.error_code : null,
+ });
+}
+
+/**
+ * Parse the public `input_files` snapshot of a history payload v2 (or an
+ * `ext_info.input_files` task freeze list) into frozen snapshots. Anything
+ * invalid degrades safely: non-arrays yield an empty list and malformed
+ * entries are skipped, so a corrupt payload can never break the page.
+ */
+export function snapshotsFromInputFiles(inputFiles: unknown): readonly SessionFileSnapshot[] {
+ if (!Array.isArray(inputFiles)) return Object.freeze([]);
+ const snapshots: SessionFileSnapshot[] = [];
+ inputFiles.forEach((entry, index) => {
+ const snapshot = toSnapshot(entry, index);
+ if (snapshot) snapshots.push(snapshot);
+ });
+ return Object.freeze(snapshots);
+}
+
+/** Files attached to one human turn, as restored from a view payload. */
+export interface ViewContextFiles {
+ version: 1 | 2;
+ inputFiles: readonly SessionFileSnapshot[];
+}
+
+/**
+ * Read the files attached to a turn from a view (react-agent) history
+ * payload. v1 predates file snapshots, so it returns an empty list and
+ * those messages render without attachment cards. Non-JSON payloads and
+ * non-react-agent contexts return null.
+ */
+export function parseViewContextFiles(context: unknown): ViewContextFiles | null {
+ if (typeof context !== 'string' || !context) return null;
+ let payload: unknown;
+ try {
+ payload = JSON.parse(context);
+ } catch {
+ return null;
+ }
+ if (typeof payload !== 'object' || payload === null) return null;
+ const wire = payload as Record;
+ if (wire.type !== 'react-agent') return null;
+ if (wire.version === 1) return { version: 1, inputFiles: Object.freeze([]) };
+ if (wire.version === REACT_HISTORY_PAYLOAD_VERSION) {
+ return { version: 2, inputFiles: snapshotsFromInputFiles(wire.input_files) };
+ }
+ return null;
+}
+
+/**
+ * Minimal public back-compat form of the legacy one-item attachment that used
+ * to be declared as `FileAttachment` inside pages/index.tsx. Provided here so
+ * all legacy-file handling flows through this adapter seam.
+ */
+export interface LegacyAttachedFile {
+ name: string;
+ size: number;
+ type: string;
+}
+
+/**
+ * Render a legacy file (FileAttachment / staged example file) as a
+ * display-only snapshot. The `legacy:` identity is non-resolvable by design
+ * and the server-side `file_path` is never surfaced, so nothing built from
+ * this snapshot can reach the file endpoints.
+ */
+export function snapshotFromLegacyFile(file: LegacyAttachedFile): SessionFileSnapshot {
+ return Object.freeze({
+ file_id: `legacy:${file.name}`,
+ name: file.name,
+ size: file.size,
+ media_type: file.type,
+ kind: '',
+ status: 'ready',
+ ordinal: 0,
+ error_code: null,
+ });
+}
+
+/**
+ * Return a copy of a send snapshot with a legacy example file staged. The
+ * caller guarantees the mutual exclusion (no `fileIds` while a legacy file is
+ * present); `extInfoForSend` still re-checks before building the payload.
+ */
+export function snapshotWithLegacyFile(
+ snapshot: SessionFilesSendSnapshot,
+ file: LegacyServerFile,
+): SessionFilesSendSnapshot {
+ return Object.freeze({ ...snapshot, legacyFile: Object.freeze({ ...file }) });
+}
+
+/** Display snapshots attached to the sent human message, in send order. */
+export function snapshotsForSend(snapshot: SessionFilesSendSnapshot): readonly SessionFileSnapshot[] {
+ const attachments = snapshot.fileIds.map(fileId => snapshot.snapshotById[fileId]);
+ const legacy = snapshot.legacyFile;
+ if (legacy) {
+ // The mixing guard in extInfoForSend keeps `attachments` empty here, so
+ // the legacy card is the only attachment appended.
+ attachments.push(snapshotFromLegacyFile({ name: legacy.name, size: legacy.size, type: legacy.media_type }));
+ }
+ return Object.freeze(attachments);
+}
+
+/**
+ * Build the send-time ext_info. Zero files reproduce the legacy payload
+ * exactly (no `file_ids`/`session_id` keys), preserving the byte-for-byte
+ * file_path / plain-text regression contract. With files it adds:
+ *
+ * - `file_ids`: ordered ids from the frozen send snapshot (the backend
+ * conflicts check forbids combining this with `file_path`, which the caller
+ * therefore only emits on the legacy path);
+ * - `session_id`: the owning conversation id, required so a later
+ * "save as scheduled task" can freeze these session-scoped files;
+ * - `input_files`: display-safe snapshots in the exact share-scrub shape
+ * (`display_key` + public metadata, never a resolvable id or path), so the
+ * scheduled-task surfaces can render what will be frozen.
+ *
+ * A staged legacy example file instead adds only `file_path`; mixing it with
+ * `file_ids` throws SESSION_FILES_MIXED_PROTOCOL before any payload is built.
+ */
+export function extInfoForSend(
+ base: Record | null | undefined,
+ snapshot: SessionFilesSendSnapshot | null | undefined,
+): Record {
+ const safeBase: Record = base ?? {};
+ if (!snapshot) {
+ return { ...safeBase };
+ }
+ const legacy = snapshot.legacyFile ?? null;
+ if (legacy) {
+ if (snapshot.fileIds.length > 0) {
+ throw new Error('SESSION_FILES_MIXED_PROTOCOL: legacy file_path cannot be combined with session file_ids');
+ }
+ return { ...safeBase, file_path: legacy.file_path };
+ }
+ if (snapshot.fileIds.length === 0) {
+ return { ...safeBase };
+ }
+ return {
+ ...safeBase,
+ file_ids: [...snapshot.fileIds],
+ session_id: snapshot.sessionId,
+ input_files: snapshot.fileIds.map((fileId, index) => {
+ const file = snapshot.snapshotById[fileId];
+ return {
+ display_key: `file-${index + 1}`,
+ name: file.name,
+ size: file.size,
+ media_type: file.media_type,
+ kind: file.kind,
+ status: file.status,
+ ordinal: file.ordinal,
+ };
+ }),
+ };
+}
diff --git a/src/features/assistant/session-files/index.ts b/src/features/assistant/session-files/index.ts
new file mode 100644
index 0000000..00e2ccb
--- /dev/null
+++ b/src/features/assistant/session-files/index.ts
@@ -0,0 +1,24 @@
+export { default as AttachmentMessageCards } from './AttachmentMessageCards'
+export { default as AttachmentMessageGroup } from './AttachmentMessageGroup'
+export { default as AttachmentPreview, AttachmentPreviewCloseButton, AttachmentPreviewPanelTitle } from './AttachmentPreview'
+export { default as AttachmentRail, AttachmentRailAddButton } from './AttachmentRail'
+export { useSessionFiles } from './use-session-files'
+export type { UseSessionFiles, UseSessionFilesOptions } from './use-session-files'
+export { SessionFilesApiError, previewFile, sessionFilesApi, listWorkspaceFiles, uploadWorkspaceFile, updateWorkspaceFile, deleteFile } from './api'
+export { kamidsoFileToSnapshot } from './types'
+export { extInfoForSend, snapshotsForSend, snapshotsFromInputFiles } from './home-adapter'
+export { defaultFileTitle, fileKindLabel, fileKindLine, isDataFileKind, isKnowledgeFileKind } from './fileKind'
+export {
+ DATA_FILE_ACCEPT,
+ KNOWLEDGE_FILE_ACCEPT,
+} from './fileKind'
+export { DEFAULT_UPLOAD_CAPABILITIES } from './types'
+export type {
+ DraftFile,
+ KamidsoFile,
+ LegacyServerFile,
+ SessionFilePreviewSnapshot,
+ SessionFileSnapshot,
+ SessionFilesSendSnapshot,
+ UploadCapabilities,
+} from './types'
diff --git a/src/features/assistant/session-files/recovery.ts b/src/features/assistant/session-files/recovery.ts
new file mode 100644
index 0000000..6ca043a
--- /dev/null
+++ b/src/features/assistant/session-files/recovery.ts
@@ -0,0 +1,112 @@
+/**
+ * Recovery view-model for the public share page and scheduled-task surfaces.
+ *
+ * Owner-side history resolves real `sf_…` server ids so previews can call the
+ * auth-protected endpoints. The surfaces served by this module must never do
+ * that:
+ *
+ * - the public share page renders names/statuses only; a snapshot identity is
+ * always the non-resolvable `display_key` (or a synthetic placeholder), so
+ * no component can construct a preview/download URL from a share payload —
+ * even if a stored payload was polluted with a real private ``file_id``;
+ * - scheduled-task surfaces render the frozen `ext_info.input_files` list the
+ * same way (metadata only), while the replay-time task-scoped `file_ids`
+ * stay server-side;
+ * - legacy tasks keep `ext_info.file_path` display-only: only the basename is
+ * shown, and it is never turned into a URL.
+ *
+ * Everything here is DOM/JSX-free so it can be verified with `node --test`
+ * (recovery.test.ts); the .tsx pages only wire these results into Ant Design.
+ */
+
+import { REACT_HISTORY_PAYLOAD_VERSION, snapshotsFromInputFiles } from './home-adapter';
+import type { SessionFileSnapshot } from './types';
+
+// ---------------------------------------------------------------------------
+// Display-only snapshots (share page + scheduled task surfaces)
+// ---------------------------------------------------------------------------
+
+/**
+ * Drop a stored `file_id` before parsing so the base adapter can never adopt
+ * a resolvable identity. Non-entry values pass through untouched (the base
+ * parser skips them deterministically).
+ */
+function stripPrivateIdentity(entry: unknown): unknown {
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return entry;
+ const wire = { ...(entry as Record) };
+ delete wire.file_id;
+ return wire;
+}
+
+/**
+ * Parse wire `input_files` entries into frozen snapshots whose identity is
+ * never a server-resolvable id. Display-keyed entries keep their
+ * `display_key`; entries that wrongly carry a private `file_id` are
+ * identity-stripped and fall back to a synthetic placeholder; names, sizes,
+ * statuses and ordinals render unchanged either way.
+ */
+export function displayOnlySnapshots(inputFiles: unknown): readonly SessionFileSnapshot[] {
+ if (!Array.isArray(inputFiles)) return Object.freeze([]);
+ return snapshotsFromInputFiles(inputFiles.map(stripPrivateIdentity));
+}
+
+// ---------------------------------------------------------------------------
+// Share replay payload gate
+// ---------------------------------------------------------------------------
+
+export interface ShareViewPayload {
+ version: 1 | 2;
+ /** The parsed react-agent payload; callers narrow the fields they render. */
+ payload: Record;
+}
+
+/**
+ * Accept react history versions 1 and 2 for public replay. Anything else
+ * (malformed JSON, non-react-agent payloads, unknown future versions)
+ * yields null so the replay page can skip the message without crashing.
+ */
+export function parseShareViewPayload(context: unknown): ShareViewPayload | null {
+ if (typeof context !== 'string' || !context) return null;
+ let payload: unknown;
+ try {
+ payload = JSON.parse(context);
+ } catch {
+ return null;
+ }
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return null;
+ const wire = payload as Record;
+ if (wire.type !== 'react-agent') return null;
+ if (wire.version === 1) return { version: 1, payload: wire };
+ if (wire.version === REACT_HISTORY_PAYLOAD_VERSION) {
+ return { version: 2, payload: wire };
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Scheduled task surfaces
+// ---------------------------------------------------------------------------
+
+/**
+ * Render-model of a task payload's frozen file list
+ * (`payload.ext_info.input_files`). The replay-time task-scoped
+ * `ext_info.file_ids` are server-side keys and are never surfaced here;
+ * legacy tasks (which carry only `file_path`) render an empty list.
+ */
+export function scheduledTaskFiles(extInfo: unknown): readonly SessionFileSnapshot[] {
+ if (typeof extInfo !== 'object' || extInfo === null || Array.isArray(extInfo)) {
+ return Object.freeze([]);
+ }
+ return displayOnlySnapshots((extInfo as Record).input_files);
+}
+
+/**
+ * Display-only label for a legacy task's `ext_info.file_path`: just the
+ * basename. The full server path is never rendered into a URL, link or
+ * download affordance. Malformed values yield null so the row can hide.
+ */
+export function legacyFilePathDisplayName(filePath: unknown): string | null {
+ if (typeof filePath !== 'string' || !filePath.trim()) return null;
+ const segments = filePath.split(/[\\/]+/).filter(segment => segment.length > 0);
+ return segments.length > 0 ? segments[segments.length - 1] : null;
+}
diff --git a/src/features/assistant/session-files/reducer.ts b/src/features/assistant/session-files/reducer.ts
new file mode 100644
index 0000000..87fff8c
--- /dev/null
+++ b/src/features/assistant/session-files/reducer.ts
@@ -0,0 +1,359 @@
+/**
+ * Pure reducer for session-file drafts.
+ *
+ * Validation, upload, and preview are orthogonal sub-states:
+ * a validation failure blocks queueing, an upload failure hard-blocks
+ * sending, and a preview failure is only a soft warning.
+ */
+
+import type {
+ CapabilitiesSource,
+ DraftFile,
+ LegacyServerFile,
+ SessionFileSnapshot,
+ SessionFilesSendSnapshot,
+ UploadCapabilities,
+} from './types';
+import { DEFAULT_UPLOAD_CAPABILITIES } from './types';
+
+export interface SessionFilesState {
+ sessionId: string | null;
+ files: DraftFile[];
+ capabilities: UploadCapabilities | null;
+ capabilitiesSource: CapabilitiesSource;
+ /**
+ * Legacy server-preloaded file (example cards). Mutually exclusive with
+ * `files`: the legacy file_path protocol never mixes with session file_ids.
+ */
+ legacyFile: LegacyServerFile | null;
+}
+
+export const initialSessionFilesState = (): SessionFilesState => ({
+ sessionId: null,
+ files: [],
+ capabilities: null,
+ capabilitiesSource: 'fallback',
+ legacyFile: null,
+});
+
+export interface AddInput {
+ clientId: string;
+ file: File;
+ title?: string;
+}
+
+export type SessionFilesAction =
+ | { type: 'bind_session'; sessionId: string }
+ | {
+ type: 'capabilities';
+ capabilities: UploadCapabilities;
+ source: CapabilitiesSource;
+ }
+ | { type: 'add'; inputs: AddInput[] }
+ | { type: 'attach_snapshots'; snapshots: SessionFileSnapshot[] }
+ | { type: 'remove'; clientId: string }
+ | { type: 'upload_start'; clientId: string; attempt: number }
+ | { type: 'upload_progress'; clientId: string; attempt: number; progress: number }
+ | {
+ type: 'upload_done';
+ clientId: string;
+ attempt: number;
+ snapshot: SessionFileSnapshot;
+ }
+ | { type: 'upload_failed'; clientId: string; attempt: number; error: string }
+ | { type: 'upload_cancelled'; clientId: string; attempt: number }
+ | { type: 'retry'; clientId: string }
+ | { type: 'preview_start'; clientId: string }
+ | { type: 'preview_ready'; clientId: string }
+ | { type: 'preview_failed'; clientId: string; error: string }
+ | { type: 'rehydrate'; sessionId: string; snapshots: SessionFileSnapshot[] }
+ | { type: 'set_legacy'; file: LegacyServerFile }
+ | { type: 'clear_legacy' }
+ | { type: 'clear_turn' }
+ | { type: 'reset_session' };
+
+export const identityOf = (file: File): { name: string; size: number; lastModified: number } => ({
+ name: file.name,
+ size: file.size,
+ lastModified: file.lastModified,
+});
+
+const identityKey = (identity: { name: string; size: number; lastModified: number }): string =>
+ `${identity.name}::${identity.size}::${identity.lastModified}`;
+
+const makeDraft = (clientId: string, file: File): DraftFile => ({
+ clientId,
+ file,
+ identity: identityOf(file),
+ validation: { status: 'ok', error: null },
+ upload: { status: 'queued', progress: 0, attempt: 0, error: null },
+ preview: { status: 'idle', error: null },
+ snapshot: null,
+});
+
+const snapshotToDraft = (snapshot: SessionFileSnapshot): DraftFile => {
+ const failed = snapshot.status === 'failed';
+ return {
+ clientId: `server:${snapshot.file_id}`,
+ file: null,
+ identity: { name: snapshot.name, size: snapshot.size, lastModified: 0 },
+ validation: { status: 'ok', error: null },
+ upload: {
+ status: failed ? 'failed' : 'done',
+ progress: failed ? 0 : 1,
+ attempt: 1,
+ error: failed ? (snapshot.error_code ?? 'failed') : null,
+ },
+ preview: {
+ status: snapshot.status === 'ready' ? 'ready' : snapshot.status === 'preview_failed' ? 'preview_failed' : 'idle',
+ error: snapshot.status === 'preview_failed' ? (snapshot.error_code ?? null) : null,
+ },
+ snapshot,
+ };
+};
+
+/**
+ * Plan a batch add against the current drafts and capabilities.
+ * Pure: duplicates (by identity), over-count and over-byte picks become
+ * invalid/blocked drafts; everything else stays queued. Order is preserved.
+ */
+export function planAddDrafts(args: {
+ existing: readonly DraftFile[];
+ files: readonly File[];
+ capabilities: UploadCapabilities;
+ createClientId?: () => string;
+}): DraftFile[] {
+ const { existing, files, capabilities } = args;
+ let fallbackCounter = 0;
+ const createClientId =
+ args.createClientId ??
+ (() =>
+ typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
+ ? crypto.randomUUID()
+ : `draft-${Date.now()}-${fallbackCounter++}`);
+
+ const seen = new Set(existing.map(draft => identityKey(draft.identity)));
+ let acceptedCount = existing.filter(draft => draft.validation.status === 'ok').length;
+ let acceptedBytes = existing
+ .filter(draft => draft.validation.status === 'ok')
+ .reduce((sum, draft) => sum + draft.identity.size, 0);
+
+ return files.map(file => {
+ const draft = makeDraft(createClientId(), file);
+ const key = identityKey(draft.identity);
+ if (seen.has(key)) {
+ draft.validation = { status: 'invalid', error: 'DUPLICATE_FILE' };
+ draft.upload = { ...draft.upload, status: 'blocked' };
+ } else if (file.size > capabilities.max_file_bytes) {
+ draft.validation = { status: 'invalid', error: 'FILE_TOO_LARGE' };
+ draft.upload = { ...draft.upload, status: 'blocked' };
+ } else if (acceptedCount + 1 > capabilities.max_files_per_upload) {
+ draft.validation = { status: 'invalid', error: 'TOO_MANY_FILES' };
+ draft.upload = { ...draft.upload, status: 'blocked' };
+ } else if (acceptedBytes + file.size > capabilities.max_upload_bytes) {
+ draft.validation = { status: 'invalid', error: 'REQUEST_TOO_LARGE' };
+ draft.upload = { ...draft.upload, status: 'blocked' };
+ } else {
+ seen.add(key);
+ acceptedCount += 1;
+ acceptedBytes += file.size;
+ }
+ return draft;
+ });
+}
+
+const mapDraft = (
+ state: SessionFilesState,
+ clientId: string,
+ fn: (draft: DraftFile) => DraftFile,
+): SessionFilesState => {
+ const index = state.files.findIndex(draft => draft.clientId === clientId);
+ if (index < 0) return state; // Stale signal for a removed draft: ignore.
+ const files = state.files.slice();
+ files[index] = fn(files[index]);
+ return { ...state, files };
+};
+
+export function sessionFilesReducer(state: SessionFilesState, action: SessionFilesAction): SessionFilesState {
+ switch (action.type) {
+ case 'bind_session':
+ return { ...state, sessionId: action.sessionId };
+
+ case 'capabilities':
+ return { ...state, capabilities: action.capabilities, capabilitiesSource: action.source };
+
+ case 'add': {
+ // Legacy file_path and session file_ids are mutually exclusive: while a
+ // legacy example file is staged, local picks are refused wholesale.
+ if (state.legacyFile) return state;
+ const capabilities = state.capabilities ?? DEFAULT_UPLOAD_CAPABILITIES;
+ const planned = planAddDrafts({
+ existing: state.files,
+ files: action.inputs.map(input => input.file),
+ capabilities,
+ });
+ // Caller-supplied clientIds win (planned order matches input order).
+ const drafts = planned.map((draft, index) => {
+ const title = action.inputs[index]?.title?.trim();
+ return {
+ ...draft,
+ clientId: action.inputs[index]?.clientId ?? draft.clientId,
+ title: title || undefined,
+ };
+ });
+ // The reducer stays authoritative on duplicates, even if the caller
+ // planned against stale state.
+ const seen = new Set(state.files.map(draft => identityKey(draft.identity)));
+ const accepted = drafts.filter(draft => {
+ const key = identityKey(draft.identity);
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+ return { ...state, files: [...state.files, ...accepted] };
+ }
+
+ case 'attach_snapshots': {
+ if (state.legacyFile) return state;
+ const existing = new Set(
+ state.files.map(draft => draft.snapshot?.file_id).filter((id): id is string => Boolean(id)),
+ );
+ const extra = action.snapshots
+ .filter(snapshot => snapshot.status !== 'deleted' && !existing.has(snapshot.file_id))
+ .map(snapshotToDraft);
+ if (extra.length === 0) return state;
+ return { ...state, files: [...state.files, ...extra] };
+ }
+
+ case 'remove':
+ return { ...state, files: state.files.filter(draft => draft.clientId !== action.clientId) };
+
+ case 'upload_start':
+ return mapDraft(state, action.clientId, draft =>
+ draft.upload.status !== 'queued'
+ ? draft
+ : patchUpload(draft, { status: 'uploading', attempt: action.attempt, progress: 0, error: null }),
+ );
+
+ case 'upload_progress':
+ return mapDraft(state, action.clientId, draft =>
+ draft.upload.status !== 'uploading' || draft.upload.attempt !== action.attempt
+ ? draft
+ : patchUpload(draft, { progress: Math.min(1, Math.max(0, action.progress)) }),
+ );
+
+ case 'upload_done':
+ return mapDraft(state, action.clientId, draft =>
+ draft.upload.status !== 'uploading' || draft.upload.attempt !== action.attempt
+ ? draft
+ : {
+ ...patchUpload(draft, { status: 'done', progress: 1, error: null }),
+ snapshot: action.snapshot,
+ },
+ );
+
+ case 'upload_failed':
+ return mapDraft(state, action.clientId, draft =>
+ draft.upload.status !== 'uploading' || draft.upload.attempt !== action.attempt
+ ? draft
+ : patchUpload(draft, { status: 'failed', error: action.error }),
+ );
+
+ case 'upload_cancelled':
+ return mapDraft(state, action.clientId, draft =>
+ (draft.upload.status !== 'uploading' && draft.upload.status !== 'queued') ||
+ draft.upload.attempt !== action.attempt
+ ? draft
+ : patchUpload(draft, { status: 'cancelled', progress: 0 }),
+ );
+
+ case 'retry':
+ return mapDraft(state, action.clientId, draft =>
+ draft.upload.status !== 'failed' ? draft : patchUpload(draft, { status: 'queued', progress: 0, error: null }),
+ );
+
+ case 'preview_start':
+ return mapDraft(state, action.clientId, draft =>
+ draft.upload.status !== 'done' ? draft : patchPreview(draft, { status: 'loading', error: null }),
+ );
+
+ case 'preview_ready':
+ return mapDraft(state, action.clientId, draft =>
+ draft.preview.status !== 'loading' ? draft : patchPreview(draft, { status: 'ready', error: null }),
+ );
+
+ case 'preview_failed':
+ return mapDraft(state, action.clientId, draft =>
+ draft.preview.status !== 'loading'
+ ? draft
+ : patchPreview(draft, { status: 'preview_failed', error: action.error }),
+ );
+
+ case 'rehydrate': {
+ const drafts = action.snapshots
+ .filter(snapshot => snapshot.status !== 'deleted')
+ .slice()
+ .sort((a, b) => a.ordinal - b.ordinal)
+ .map(snapshotToDraft);
+ return { ...state, sessionId: action.sessionId, files: drafts, legacyFile: null };
+ }
+
+ case 'set_legacy': {
+ // Mutually exclusive with local drafts; re-staging is idempotent —
+ // the first staged example file wins until explicitly cleared.
+ if (state.files.length > 0 || state.legacyFile) return state;
+ return { ...state, legacyFile: { ...action.file } };
+ }
+
+ case 'clear_legacy':
+ return { ...state, legacyFile: null };
+
+ case 'clear_turn':
+ return { ...state, files: [], legacyFile: null };
+
+ case 'reset_session':
+ return { ...state, sessionId: null, files: [], legacyFile: null };
+
+ default:
+ return state;
+ }
+}
+
+const patchUpload = (draft: DraftFile, patch: Partial): DraftFile => ({
+ ...draft,
+ upload: { ...draft.upload, ...patch },
+});
+
+const patchPreview = (draft: DraftFile, patch: Partial): DraftFile => ({
+ ...draft,
+ preview: { ...draft.preview, ...patch },
+});
+
+/** Upload failures are the only hard gate for sending. */
+export function hasHardFailures(state: SessionFilesState): boolean {
+ return state.files.some(draft => draft.upload.status === 'failed');
+}
+
+/**
+ * Build the immutable, insertion-ordered send snapshot.
+ * Throws when any draft has a hard (upload) failure. A staged legacy file is
+ * carried through so the send path can resend it via the legacy file_path
+ * protocol; the reducer's mixing guards already keep legacy and drafts
+ * mutually exclusive, so the two never appear together here.
+ */
+export function buildSendSnapshot(state: SessionFilesState, sessionId: string): SessionFilesSendSnapshot {
+ const failed = state.files.filter(draft => draft.upload.status === 'failed');
+ if (failed.length > 0) {
+ const names = failed.map(draft => draft.identity.name).join(', ');
+ throw new Error(`SESSION_FILES_HARD_FAILURE: resolve failed uploads before send (${names})`);
+ }
+ const uploaded = state.files.filter(
+ (draft): draft is DraftFile & { snapshot: SessionFileSnapshot } => draft.snapshot !== null,
+ );
+ const fileIds = Object.freeze(uploaded.map(draft => draft.snapshot.file_id));
+ const snapshotById = Object.freeze(
+ Object.fromEntries(uploaded.map(draft => [draft.snapshot.file_id, Object.freeze({ ...draft.snapshot })])),
+ );
+ const legacyFile = state.legacyFile ? Object.freeze({ ...state.legacyFile }) : null;
+ return Object.freeze({ sessionId, fileIds, snapshotById, legacyFile });
+}
diff --git a/src/features/assistant/session-files/types.ts b/src/features/assistant/session-files/types.ts
new file mode 100644
index 0000000..5e3126d
--- /dev/null
+++ b/src/features/assistant/session-files/types.ts
@@ -0,0 +1,195 @@
+/**
+ * Types for the session-files module.
+ *
+ * Wire types mirror Kamidso product file API (/api/kamidso/v1/files).
+ * Local draft types model the client-side upload/preview state machines.
+ */
+
+/** Kamidso canonical file record (public fields only). */
+export interface KamidsoFile {
+ id: string
+ name: string
+ size_bytes: number
+ mime_type: string
+ status: 'processing' | 'ready' | 'failed'
+ created_at: string
+ title?: string
+ original_name?: string
+ kind?: string
+ extension?: string
+}
+
+export function kamidsoFileToSnapshot(file: KamidsoFile, ordinal = 0): SessionFileSnapshot {
+ const statusMap: Record = {
+ processing: 'uploading',
+ ready: 'ready',
+ failed: 'failed',
+ }
+ return Object.freeze({
+ file_id: String(file.id || ''),
+ name: file.title?.trim() || file.name,
+ size: file.size_bytes,
+ media_type: file.mime_type,
+ kind: file.kind || 'file',
+ status: statusMap[file.status] ?? 'ready',
+ ordinal,
+ error_code: file.status === 'failed' ? 'UPLOAD_FAILED' : null,
+ })
+}
+
+/** Client-side lifecycle status for drafts and server snapshots. */
+export type SessionFileStatus = 'uploading' | 'inspecting' | 'ready' | 'preview_failed' | 'failed' | 'deleted';
+
+/** Backend snapshot of one session file (SessionFileResponse). */
+export interface SessionFileSnapshot {
+ file_id: string;
+ name: string;
+ size: number;
+ media_type: string;
+ kind: string;
+ status: SessionFileStatus;
+ /** Upload order within the scope. */
+ ordinal: number;
+ error_code?: string | null;
+}
+
+/** Backend preview payload (SessionFilePreviewResponse); adapter only. */
+export interface SessionFilePreviewSnapshot {
+ file_id: string;
+ name: string;
+ media_type: string;
+ kind: string;
+ status: SessionFileStatus;
+ truncated: boolean;
+ preview: Record;
+ error_code?: string | null;
+}
+
+/** Server-owned upload limits (SessionFileCapabilitiesResponse). */
+export interface UploadCapabilities {
+ max_files_per_upload: number;
+ max_file_bytes: number;
+ max_upload_bytes: number;
+ max_owner_bytes: number;
+ upload_request_timeout_seconds: number;
+ upload_concurrency: number;
+ supported_extensions: string[];
+}
+
+/** Conservative defaults mirroring ServeConfig; used when /capabilities is unreachable. */
+export const DEFAULT_UPLOAD_CAPABILITIES: UploadCapabilities = {
+ max_files_per_upload: 20,
+ max_file_bytes: 100 * 1024 * 1024,
+ max_upload_bytes: 500 * 1024 * 1024,
+ max_owner_bytes: 1024 * 1024 * 1024,
+ upload_request_timeout_seconds: 180,
+ upload_concurrency: 3,
+ supported_extensions: [
+ '.csv',
+ '.tsv',
+ '.xls',
+ '.xlsx',
+ '.json',
+ '.jsonl',
+ '.parquet',
+ '.pdf',
+ '.doc',
+ '.docx',
+ '.pptx',
+ '.md',
+ '.txt',
+ ],
+};
+
+export type CapabilitiesSource = 'server' | 'fallback';
+
+export interface CapabilitiesResult {
+ capabilities: UploadCapabilities;
+ source: CapabilitiesSource;
+ /** Present when the server could not be honored and defaults were used. */
+ error?: unknown;
+}
+
+/** Adapter seam implemented by api.ts; injected into the hook for testability. */
+export interface SessionFilesApi {
+ uploadFiles(params: {
+ sessionId: string;
+ files: readonly File[];
+ title?: string;
+ signal?: AbortSignal;
+ onProgress?: (ratio: number) => void;
+ timeoutMs?: number;
+ }): Promise;
+ listFiles(sessionId: string): Promise;
+ deleteFile(sessionId: string, fileId: string): Promise;
+ fetchCapabilities(): Promise;
+ /** Optional: preview endpoints exist server-side; the adapter keeps the seam ready. */
+ previewFile?(sessionId: string, fileId: string): Promise;
+}
+
+/** Identity used to detect duplicate local picks. */
+export interface FileIdentity {
+ name: string;
+ size: number;
+ lastModified: number;
+}
+
+/** Client-side upload state machine (orthogonal to validation and preview). */
+export type UploadPhase =
+ | 'queued'
+ | 'uploading'
+ | 'done'
+ | 'failed'
+ | 'cancelled'
+ /** Validation rejected the file; it never enters the upload queue. */
+ | 'blocked';
+
+/** Client-side preview state machine (orthogonal to upload). */
+export type PreviewPhase = 'idle' | 'loading' | 'ready' | 'preview_failed';
+
+export type ValidationStatus = 'ok' | 'invalid';
+
+export interface DraftFile {
+ /** Stable client-side id; kept across retries. */
+ clientId: string;
+ /** Local bytes; null for drafts rehydrated from the server list. */
+ file: File | null;
+ /** User-facing title; the original filename stays on identity/file. */
+ title?: string;
+ identity: FileIdentity;
+ validation: { status: ValidationStatus; error: string | null };
+ upload: {
+ status: UploadPhase;
+ /** 0..1 */
+ progress: number;
+ /** Monotonic per-draft attempt token; stale completions carry an old token. */
+ attempt: number;
+ error: string | null;
+ };
+ preview: { status: PreviewPhase; error: string | null };
+ /** Server snapshot once uploaded (or rehydrated). */
+ snapshot: SessionFileSnapshot | null;
+}
+
+/**
+ * Legacy server-side file preloaded by an example card. The file already
+ * exists on the server (identified only by its path), so it is displayed
+ * read-only in the composer rail and resent verbatim via the legacy
+ * `ext_info.file_path` protocol — mutually exclusive with `file_ids`.
+ */
+export interface LegacyServerFile {
+ name: string;
+ size: number;
+ media_type: string;
+ /** Server-side path echoed back as ext_info.file_path (legacy protocol). */
+ file_path: string;
+}
+
+/** Immutable, ordered payload handed to the send flow. */
+export interface SessionFilesSendSnapshot {
+ readonly sessionId: string;
+ readonly fileIds: readonly string[];
+ readonly snapshotById: Readonly>;
+ /** Legacy staged file for this send; mutually exclusive with `fileIds`. */
+ readonly legacyFile?: LegacyServerFile | null;
+}
diff --git a/src/features/assistant/session-files/upload-queue.ts b/src/features/assistant/session-files/upload-queue.ts
new file mode 100644
index 0000000..6f139e8
--- /dev/null
+++ b/src/features/assistant/session-files/upload-queue.ts
@@ -0,0 +1,185 @@
+/**
+ * In-process upload command queue.
+ *
+ * - Bounded concurrency (default MAX_UPLOAD_CONCURRENCY).
+ * - Stable FIFO start order; task results always resolve (never reject),
+ * so one failure can never take down its siblings.
+ * - Aborted tasks report 'cancelled' and are never treated as retryable
+ * network errors; retry requeues failed tasks only.
+ */
+
+export const MAX_UPLOAD_CONCURRENCY = 3;
+
+export type QueueSettledStatus = 'done' | 'failed' | 'cancelled';
+
+export interface QueueTaskResult {
+ clientId: string;
+ status: QueueSettledStatus;
+ value?: T;
+ error?: unknown;
+}
+
+export interface UploadQueueOptions {
+ concurrency?: number;
+}
+
+type TaskStatus = 'pending' | 'running' | QueueSettledStatus;
+
+interface QueueTask {
+ clientId: string;
+ run: (signal: AbortSignal) => Promise;
+ controller: AbortController;
+ status: TaskStatus;
+ aborted: boolean;
+ result?: QueueTaskResult;
+ promise: Promise>;
+ resolve: (result: QueueTaskResult) => void;
+}
+
+function makeTask(clientId: string, run: (signal: AbortSignal) => Promise): QueueTask {
+ const task: Partial> = {
+ clientId,
+ run,
+ controller: new AbortController(),
+ status: 'pending',
+ aborted: false,
+ result: undefined,
+ };
+ task.promise = new Promise>(resolve => {
+ task.resolve = resolve;
+ });
+ return task as QueueTask;
+}
+
+export class UploadQueue {
+ private readonly concurrency: number;
+ private readonly pending: QueueTask[] = [];
+ private readonly running = new Set>();
+ private readonly tasks = new Map>();
+
+ constructor(options: UploadQueueOptions = {}) {
+ const concurrency = options.concurrency ?? MAX_UPLOAD_CONCURRENCY;
+ this.concurrency = Math.max(1, Math.floor(concurrency));
+ }
+
+ get activeCount(): number {
+ return this.running.size;
+ }
+
+ get pendingCount(): number {
+ return this.pending.length;
+ }
+
+ result(clientId: string): QueueTaskResult | undefined {
+ return this.tasks.get(clientId)?.result;
+ }
+
+ /** Enqueue a task; the returned promise always resolves (never rejects). */
+ enqueue(clientId: string, run: (signal: AbortSignal) => Promise): Promise> {
+ const task = makeTask(clientId, run);
+ this.tasks.set(clientId, task);
+ this.pending.push(task);
+ this.pump();
+ return task.promise;
+ }
+
+ private pump(): void {
+ while (this.running.size < this.concurrency && this.pending.length > 0) {
+ const task = this.pending.shift()!;
+ this.running.add(task);
+ task.status = 'running';
+ void this.execute(task);
+ }
+ }
+
+ private async execute(task: QueueTask): Promise {
+ try {
+ const value = await task.run(task.controller.signal);
+ this.finish(task, { clientId: task.clientId, status: task.aborted ? 'cancelled' : 'done', value });
+ } catch (error) {
+ // An abort is never reported as a retryable failure, whatever the
+ // underlying run() rejected with.
+ this.finish(
+ task,
+ task.aborted
+ ? { clientId: task.clientId, status: 'cancelled' }
+ : { clientId: task.clientId, status: 'failed', error },
+ );
+ }
+ }
+
+ private finish(task: QueueTask, result: QueueTaskResult): void {
+ task.status = result.status;
+ task.result = result;
+ this.running.delete(task);
+ task.resolve(result);
+ this.pump();
+ }
+
+ /**
+ * Cancel a pending or running task. Pending tasks never run; running
+ * tasks get an aborted signal and settle as 'cancelled' (never 'failed').
+ * Returns false when the clientId is unknown.
+ */
+ cancel(clientId: string): boolean {
+ const pendingIndex = this.pending.findIndex(task => task.clientId === clientId);
+ if (pendingIndex >= 0) {
+ const [task] = this.pending.splice(pendingIndex, 1);
+ task.aborted = true;
+ task.controller.abort();
+ this.finish(task, { clientId, status: 'cancelled' });
+ return true;
+ }
+ for (const task of this.running) {
+ if (task.clientId !== clientId) continue;
+ task.aborted = true;
+ task.controller.abort();
+ return true;
+ }
+ return false;
+ }
+
+ /** Cancel everything pending or running (e.g. on turn cleanup). */
+ cancelAll(): void {
+ for (const task of [...this.pending, ...this.running]) {
+ this.cancel(task.clientId);
+ }
+ }
+
+ /**
+ * Requeue one failed task with a fresh controller and promise.
+ * Returns null when the task is unknown or not in 'failed' state —
+ * cancelled tasks are explicitly not retryable.
+ */
+ retry(clientId: string): Promise> | null {
+ const task = this.tasks.get(clientId);
+ if (!task || task.result?.status !== 'failed') return null;
+ task.controller = new AbortController();
+ task.aborted = false;
+ task.status = 'pending';
+ task.result = undefined;
+ task.promise = new Promise>(resolve => {
+ task.resolve = resolve;
+ });
+ this.pending.push(task);
+ this.pump();
+ return task.promise;
+ }
+
+ /** Requeue all failed tasks, preserving FIFO order. Returns their clientIds. */
+ retryFailed(): string[] {
+ const retried: string[] = [];
+ for (const task of this.tasks.values()) {
+ if (task.result?.status !== 'failed') continue;
+ if (this.retry(task.clientId)) retried.push(task.clientId);
+ }
+ return retried;
+ }
+
+ /** Resolve once every queued task has settled (failures included). */
+ async drain(): Promise {
+ while (this.pending.length > 0 || this.running.size > 0) {
+ await Promise.allSettled([...this.pending, ...this.running].map(task => task.promise));
+ }
+ }
+}
diff --git a/src/features/assistant/session-files/use-session-files.ts b/src/features/assistant/session-files/use-session-files.ts
new file mode 100644
index 0000000..e311d50
--- /dev/null
+++ b/src/features/assistant/session-files/use-session-files.ts
@@ -0,0 +1,425 @@
+/**
+ * React orchestration for session-file drafts.
+ *
+ * State lives in the pure reducer; the network lives behind the injected
+ * SessionFilesApi seam; concurrency is owned by UploadQueue. Everything that
+ * must survive re-renders (latest state, api, attempt tokens, capabilities
+ * promise) lives in refs so captured callbacks never read stale closures.
+ */
+
+import { useCallback, useEffect, useReducer, useRef } from 'react';
+
+import * as reducerModule from './reducer';
+import type {
+ DraftFile,
+ LegacyServerFile,
+ SessionFileSnapshot,
+ SessionFilesApi,
+ SessionFilesSendSnapshot,
+ UploadCapabilities,
+} from './types';
+import type { QueueSettledStatus } from './upload-queue';
+import { MAX_UPLOAD_CONCURRENCY, UploadQueue } from './upload-queue';
+
+export interface UseSessionFilesOptions {
+ api: SessionFilesApi;
+ concurrency?: number;
+ /** Injectable for deterministic tests; defaults to crypto.randomUUID. */
+ createClientId?: () => string;
+}
+
+export type StageLegacyForSendResult = { ok: true; snapshot: SessionFilesSendSnapshot } | { ok: false; error: string };
+
+const isValidLegacyFile = (file: LegacyServerFile): boolean =>
+ !!file && typeof file.file_path === 'string' && !!file.file_path.trim() && !!file.name?.trim();
+
+const isSameLegacyFile = (left: LegacyServerFile, right: LegacyServerFile): boolean =>
+ left.name === right.name &&
+ left.size === right.size &&
+ left.media_type === right.media_type &&
+ left.file_path === right.file_path;
+
+export interface UseSessionFiles {
+ files: readonly DraftFile[];
+ sessionId: string | null;
+ capabilities: UploadCapabilities | null;
+ capabilitiesSource: 'server' | 'fallback';
+ hasHardFailures: boolean;
+ isUploading: boolean;
+ /**
+ * Legacy server-preloaded file staged by an example card (read-only in the
+ * rail). Mutually exclusive with local drafts: while staged, `addFiles` is
+ * refused; while drafts exist, `stageLegacyForSend` is refused.
+ */
+ legacyFile: LegacyServerFile | null;
+ addFiles: (incoming: File[] | ArrayLike, sessionId: string, options?: { titles?: Array }) => Promise;
+ /** Attach already-uploaded workspace files without uploading again. */
+ attachLibraryFiles: (snapshots: SessionFileSnapshot[], sessionId?: string | null) => void;
+ remove: (clientId: string) => void;
+ cancel: (clientId: string) => void;
+ retryFailed: () => string[];
+ /**
+ * Atomically stage a legacy server-preloaded example and return the frozen
+ * snapshot for this send. The returned snapshot is authoritative even when
+ * React has not committed the rail-state dispatch yet.
+ */
+ stageLegacyForSend: (file: LegacyServerFile, sessionId: string) => StageLegacyForSendResult;
+ /** Unstage the legacy example file without touching drafts. */
+ clearLegacyFile: () => void;
+ /** Wait for in-flight uploads and return the frozen, ordered send payload. */
+ prepare: (sessionId: string) => Promise;
+ /** Rebuild drafts from the server list for a conversation (switch-resilient). */
+ rehydrateFromServer: (sessionId: string) => Promise;
+ /** Clear the current turn's drafts while keeping the session scope. */
+ clearTurn: () => void;
+ /** Clear task-local files and detach the current session scope. */
+ resetSession: () => void;
+}
+
+export function useSessionFiles(options: UseSessionFilesOptions): UseSessionFiles {
+ const { api, concurrency = MAX_UPLOAD_CONCURRENCY } = options;
+ const [state, dispatch] = useReducer(
+ reducerModule.sessionFilesReducer,
+ undefined,
+ reducerModule.initialSessionFilesState,
+ );
+
+ const stateRef = useRef(state);
+ stateRef.current = state;
+
+ const apiRef = useRef(api);
+ apiRef.current = api;
+
+ const defaultClientId = () =>
+ typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
+ ? crypto.randomUUID()
+ : `draft-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ const createClientIdRef = useRef(options.createClientId ?? defaultClientId);
+ createClientIdRef.current = options.createClientId ?? defaultClientId;
+
+ const queueRef = useRef | null>(null);
+ if (!queueRef.current) {
+ queueRef.current = new UploadQueue({ concurrency });
+ }
+
+ // Monotonic attempt tokens per draft resist stale completions.
+ const attemptsRef = useRef(new Map());
+ // Scope changes (conversation switch/new task) invalidate late async work.
+ const scopeGenerationRef = useRef(0);
+ const capabilitiesPromiseRef = useRef | null>(null);
+
+ // Abort every in-flight upload when the owning component unmounts so
+ // requests do not run to completion (wasting bandwidth and leaving orphan
+ // files) after the UI that owns them is gone.
+ useEffect(
+ () => () => {
+ queueRef.current?.cancelAll();
+ attemptsRef.current.clear();
+ },
+ [],
+ );
+
+ const settleUpload = useCallback(
+ (
+ draft: DraftFile,
+ sessionId: string,
+ attempt: number,
+ promise: Promise<{
+ clientId: string;
+ status: QueueSettledStatus;
+ value?: SessionFileSnapshot;
+ error?: unknown;
+ }>,
+ ) => {
+ void promise.then(result => {
+ if (attemptsRef.current.get(draft.clientId) !== attempt) return; // stale attempt
+ if (result.status === 'done' && result.value) {
+ const snapshot = result.value;
+ dispatch({ type: 'upload_done', clientId: draft.clientId, attempt, snapshot });
+ const previewFile = apiRef.current.previewFile;
+ if (!previewFile) return;
+ // Preview is orthogonal: a failure here is only a soft warning.
+ dispatch({ type: 'preview_start', clientId: draft.clientId });
+ void previewFile
+ .call(apiRef.current, sessionId, snapshot.file_id)
+ .then(() => dispatch({ type: 'preview_ready', clientId: draft.clientId }))
+ .catch((error: unknown) =>
+ dispatch({
+ type: 'preview_failed',
+ clientId: draft.clientId,
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ );
+ return;
+ }
+ if (result.status === 'failed') {
+ dispatch({
+ type: 'upload_failed',
+ clientId: draft.clientId,
+ attempt,
+ error: result.error instanceof Error ? result.error.message : String(result.error),
+ });
+ }
+ // 'cancelled' results are already reflected by the cancel action.
+ });
+ },
+ [],
+ );
+
+ const launchUpload = useCallback(
+ (draft: DraftFile, sessionId: string) => {
+ const file = draft.file;
+ if (!file || draft.validation.status !== 'ok') return;
+ const queue = queueRef.current!;
+ const attempt = (attemptsRef.current.get(draft.clientId) ?? 0) + 1;
+ attemptsRef.current.set(draft.clientId, attempt);
+ dispatch({ type: 'upload_start', clientId: draft.clientId, attempt });
+
+ const uploadTimeoutSeconds = stateRef.current.capabilities?.upload_request_timeout_seconds;
+ const promise = queue.enqueue(draft.clientId, async signal => {
+ const snapshots = await apiRef.current.uploadFiles({
+ sessionId,
+ files: [file],
+ title: draft.title,
+ signal,
+ timeoutMs: uploadTimeoutSeconds != null ? uploadTimeoutSeconds * 1000 : undefined,
+ onProgress: ratio => {
+ if (attemptsRef.current.get(draft.clientId) !== attempt) return;
+ dispatch({
+ type: 'upload_progress',
+ clientId: draft.clientId,
+ attempt,
+ progress: ratio,
+ });
+ },
+ });
+ const snapshot = snapshots[0];
+ if (!snapshot) throw new Error('Upload response contained no file snapshot.');
+ return snapshot;
+ });
+ settleUpload(draft, sessionId, attempt, promise);
+ },
+ [settleUpload],
+ );
+
+ const ensureCapabilities = useCallback((): Promise => {
+ const existing = stateRef.current.capabilities;
+ if (existing) return Promise.resolve(existing);
+ if (!capabilitiesPromiseRef.current) {
+ capabilitiesPromiseRef.current = apiRef.current.fetchCapabilities().then(result => {
+ const action = { type: 'capabilities' as const, capabilities: result.capabilities, source: result.source };
+ stateRef.current = reducerModule.sessionFilesReducer(stateRef.current, action);
+ dispatch(action);
+ return result.capabilities;
+ });
+ }
+ return capabilitiesPromiseRef.current;
+ }, []);
+
+ const addFiles = useCallback(
+ async (
+ incoming: File[] | ArrayLike,
+ sessionId: string,
+ options?: { titles?: Array },
+ ): Promise => {
+ const files = Array.from(incoming as ArrayLike);
+ if (files.length === 0) return;
+ const generation = scopeGenerationRef.current;
+ // Legacy example file staged: refuse mixing with the file_ids protocol.
+ if (stateRef.current.legacyFile) return;
+ if (stateRef.current.sessionId !== sessionId) {
+ const action = { type: 'bind_session' as const, sessionId };
+ stateRef.current = reducerModule.sessionFilesReducer(stateRef.current, action);
+ dispatch(action);
+ }
+ const capabilities = await ensureCapabilities();
+ if (scopeGenerationRef.current !== generation) return;
+ // Capabilities may require a network round-trip. A legacy example can
+ // be staged while that request is in flight, so re-check the protocol
+ // intent before planning drafts or launching any uploads.
+ if (stateRef.current.legacyFile) return;
+ const drafts = reducerModule.planAddDrafts({
+ existing: stateRef.current.files,
+ files,
+ capabilities,
+ createClientId: () => createClientIdRef.current(),
+ }).map((draft, index) => {
+ const title = options?.titles?.[index]?.trim();
+ return title ? { ...draft, title } : draft;
+ });
+ if (drafts.length === 0) return;
+ const action = {
+ type: 'add' as const,
+ inputs: drafts.map(draft => ({
+ clientId: draft.clientId,
+ file: draft.file!,
+ title: draft.title,
+ })),
+ };
+ const current = stateRef.current;
+ const projected = reducerModule.sessionFilesReducer(current, action);
+ const existingClientIds = new Set(current.files.map(draft => draft.clientId));
+ const acceptedClientIds = new Set(
+ projected.files.filter(draft => !existingClientIds.has(draft.clientId)).map(draft => draft.clientId),
+ );
+ stateRef.current = projected;
+ dispatch(action);
+ for (const draft of drafts) {
+ if (acceptedClientIds.has(draft.clientId)) {
+ launchUpload(draft, sessionId);
+ }
+ }
+ },
+ [dispatch, ensureCapabilities, launchUpload],
+ );
+
+ const stageLegacyForSend = useCallback((file: LegacyServerFile, sessionId: string): StageLegacyForSendResult => {
+ if (!isValidLegacyFile(file)) {
+ return { ok: false, error: 'LEGACY_FILE_INVALID' };
+ }
+ if (!sessionId?.trim()) {
+ return { ok: false, error: 'SESSION_SCOPE_INVALID: a conversation id is required' };
+ }
+
+ const current = stateRef.current;
+ if (current.files.length > 0) {
+ // file_path and file_ids are mutually exclusive server-side.
+ return {
+ ok: false,
+ error: 'SESSION_FILES_MIXED_PROTOCOL: remove local uploads before staging a legacy example file',
+ };
+ }
+ if (current.legacyFile && !isSameLegacyFile(current.legacyFile, file)) {
+ return {
+ ok: false,
+ error: 'SESSION_FILES_LEGACY_CONFLICT: remove the staged example file before selecting another one',
+ };
+ }
+
+ // Project the exact reducer state synchronously and build the immutable
+ // send snapshot from that projection. React dispatch remains responsible
+ // only for committing the same state to the attachment rail.
+ let projected = current;
+ if (projected.sessionId !== sessionId) {
+ projected = reducerModule.sessionFilesReducer(projected, { type: 'bind_session', sessionId });
+ }
+ if (!projected.legacyFile) {
+ projected = reducerModule.sessionFilesReducer(projected, { type: 'set_legacy', file });
+ }
+
+ // Keep callback reads authoritative during React's batched window. The
+ // queued reducer actions below commit the identical projection to the UI.
+ stateRef.current = projected;
+
+ if (current.sessionId !== sessionId) {
+ dispatch({ type: 'bind_session', sessionId });
+ }
+ if (!current.legacyFile) {
+ dispatch({ type: 'set_legacy', file });
+ }
+
+ return { ok: true, snapshot: reducerModule.buildSendSnapshot(projected, sessionId) };
+ }, []);
+
+ const clearLegacyFile = useCallback(() => {
+ dispatch({ type: 'clear_legacy' });
+ }, []);
+
+ const attachLibraryFiles = useCallback((snapshots: SessionFileSnapshot[], sessionId?: string | null) => {
+ const ready = snapshots.filter((snapshot) => snapshot.file_id && snapshot.status !== 'deleted');
+ if (ready.length === 0 || stateRef.current.legacyFile) return;
+ let projected = stateRef.current;
+ if (sessionId && projected.sessionId !== sessionId) {
+ const bind = { type: 'bind_session' as const, sessionId };
+ projected = reducerModule.sessionFilesReducer(projected, bind);
+ dispatch(bind);
+ }
+ const action = { type: 'attach_snapshots' as const, snapshots: ready };
+ projected = reducerModule.sessionFilesReducer(projected, action);
+ stateRef.current = projected;
+ dispatch(action);
+ }, []);
+
+ const remove = useCallback((clientId: string) => {
+ queueRef.current!.cancel(clientId);
+ attemptsRef.current.delete(clientId);
+ dispatch({ type: 'remove', clientId });
+ }, []);
+
+ const cancel = useCallback((clientId: string) => {
+ queueRef.current!.cancel(clientId);
+ dispatch({ type: 'upload_cancelled', clientId, attempt: attemptsRef.current.get(clientId) ?? 0 });
+ }, []);
+
+ const retryFailed = useCallback((): string[] => {
+ const sessionId = stateRef.current.sessionId;
+ const failed = stateRef.current.files.filter(draft => draft.upload.status === 'failed');
+ for (const draft of failed) {
+ dispatch({ type: 'retry', clientId: draft.clientId });
+ if (sessionId) launchUpload(draft, sessionId);
+ }
+ return failed.map(draft => draft.clientId);
+ }, [launchUpload]);
+
+ const prepare = useCallback(async (sessionId: string): Promise => {
+ const bound = stateRef.current.sessionId;
+ if (bound && bound !== sessionId) {
+ throw new Error(`SESSION_SCOPE_MISMATCH: bound to ${bound}, requested ${sessionId}`);
+ }
+ if (!bound) {
+ const action = { type: 'bind_session' as const, sessionId };
+ stateRef.current = reducerModule.sessionFilesReducer(stateRef.current, action);
+ dispatch(action);
+ }
+ await queueRef.current!.drain();
+ return reducerModule.buildSendSnapshot(stateRef.current, sessionId);
+ }, []);
+
+ const rehydrateFromServer = useCallback(async (sessionId: string): Promise => {
+ const generation = ++scopeGenerationRef.current;
+ queueRef.current!.cancelAll();
+ attemptsRef.current.clear();
+ const snapshots = await apiRef.current.listFiles(sessionId);
+ if (scopeGenerationRef.current !== generation) return snapshots;
+ const action = { type: 'rehydrate' as const, sessionId, snapshots };
+ stateRef.current = reducerModule.sessionFilesReducer(stateRef.current, action);
+ dispatch(action);
+ return snapshots;
+ }, []);
+
+ const clearTurn = useCallback(() => {
+ queueRef.current!.cancelAll();
+ attemptsRef.current.clear();
+ dispatch({ type: 'clear_turn' });
+ }, []);
+
+ const resetSession = useCallback(() => {
+ scopeGenerationRef.current += 1;
+ queueRef.current!.cancelAll();
+ attemptsRef.current.clear();
+ const action = { type: 'reset_session' as const };
+ stateRef.current = reducerModule.sessionFilesReducer(stateRef.current, action);
+ dispatch(action);
+ }, []);
+
+ return {
+ files: state.files,
+ sessionId: state.sessionId,
+ capabilities: state.capabilities,
+ capabilitiesSource: state.capabilitiesSource,
+ hasHardFailures: reducerModule.hasHardFailures(state),
+ isUploading: state.files.some(draft => draft.upload.status === 'uploading' || draft.upload.status === 'queued'),
+ legacyFile: state.legacyFile,
+ addFiles,
+ attachLibraryFiles,
+ stageLegacyForSend,
+ clearLegacyFile,
+ remove,
+ cancel,
+ retryFailed,
+ prepare,
+ rehydrateFromServer,
+ clearTurn,
+ resetSession,
+ };
+}
diff --git a/src/features/assistant/state/runState.ts b/src/features/assistant/state/runState.ts
new file mode 100644
index 0000000..6afcd5b
--- /dev/null
+++ b/src/features/assistant/state/runState.ts
@@ -0,0 +1,55 @@
+import type { KamidsoArtifact, ExecutionStep, PendingQuestion, PlanTask } from '../../../contracts/run'
+import type { RunStatus } from '../../../contracts/common'
+import type { KamidsoError } from '../../../contracts/errors'
+
+export type ExecutionOutput = {
+ output_type: string
+ content: unknown
+}
+
+export type ExecutionState = {
+ steps: ExecutionStep[]
+ outputs: Record
+ activeStepId: string | null
+ stepThoughts: Record
+}
+
+export type RunState = {
+ id: string | null
+ status: RunStatus | 'idle'
+ plan: PlanTask[]
+ steps: ExecutionStep[]
+ artifacts: KamidsoArtifact[]
+ htmlByViewId: Record
+ thinkingByViewId: Record
+ answer: string
+ question: PendingQuestion | null
+ error: KamidsoError | null
+ executionByViewId: Record
+ activeViewId: string | null
+ loading: boolean
+ lastEventSequence: number
+}
+
+export function emptyExecution(): ExecutionState {
+ return { steps: [], outputs: {}, activeStepId: null, stepThoughts: {} }
+}
+
+export function initialRunState(): RunState {
+ return {
+ id: null,
+ status: 'idle',
+ plan: [],
+ steps: [],
+ artifacts: [],
+ htmlByViewId: {},
+ thinkingByViewId: {},
+ answer: '',
+ question: null,
+ error: null,
+ executionByViewId: {},
+ activeViewId: null,
+ loading: false,
+ lastEventSequence: 0,
+ }
+}
diff --git a/src/features/assistant/stream/downloadFiles.test.ts b/src/features/assistant/stream/downloadFiles.test.ts
new file mode 100644
index 0000000..1a32c56
--- /dev/null
+++ b/src/features/assistant/stream/downloadFiles.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import {
+ downloadFilesFromArtifacts,
+ mergeDownloadFiles,
+ seedFileOutputs,
+} from './downloadFiles'
+
+describe('downloadFiles helpers', () => {
+ it('extracts file artifacts', () => {
+ const files = downloadFilesFromArtifacts([
+ {
+ id: '1',
+ type: 'file',
+ name: 'best.xlsx',
+ content: { url: '/files/abc_best.xlsx', name: 'best.xlsx' },
+ },
+ { id: '2', type: 'image', name: 'chart', content: '/images/x.png' },
+ ])
+ expect(files).toEqual([{ url: '/files/abc_best.xlsx', name: 'best.xlsx' }])
+ })
+
+ it('seeds execution outputs for restore', () => {
+ const outputs = seedFileOutputs([
+ {
+ id: '1',
+ type: 'file',
+ name: 'best.xlsx',
+ content: { url: '/files/abc_best.xlsx', name: 'best.xlsx' },
+ },
+ ])
+ expect(outputs.__exports).toHaveLength(1)
+ expect(outputs.__exports[0].output_type).toBe('file')
+ })
+
+ it('merges unique download lists', () => {
+ const merged = mergeDownloadFiles(
+ [{ url: '/files/a.xlsx', name: 'a.xlsx' }],
+ [{ url: '/files/a.xlsx', name: 'a.xlsx' }, { url: '/files/b.xlsx', name: 'b.xlsx' }],
+ )
+ expect(merged).toHaveLength(2)
+ })
+})
diff --git a/src/features/assistant/stream/downloadFiles.ts b/src/features/assistant/stream/downloadFiles.ts
new file mode 100644
index 0000000..9dcd8d9
--- /dev/null
+++ b/src/features/assistant/stream/downloadFiles.ts
@@ -0,0 +1,69 @@
+import type { KamidsoArtifact } from '../../../contracts/run'
+import type { DownloadableFile } from '../artifacts/FileDownloadCard'
+import { parseDownloadableFile } from '../artifacts/FileDownloadCard'
+import type { ExecutionOutput } from '../state/runState'
+
+/** Collect downloadable file outputs from a turn's execution steps. */
+export function downloadFilesFromExecution(
+ execution?: { outputs?: Record } | null,
+): DownloadableFile[] {
+ if (!execution?.outputs) return []
+ const seen = new Set()
+ const files: DownloadableFile[] = []
+ for (const stepOutputs of Object.values(execution.outputs)) {
+ for (const output of stepOutputs) {
+ if (output.output_type !== 'file') continue
+ const parsed = parseDownloadableFile(output.content)
+ if (!parsed || seen.has(parsed.url)) continue
+ seen.add(parsed.url)
+ files.push(parsed)
+ }
+ }
+ return files
+}
+
+/** Extract Excel/CSV downloads from persisted run artifacts. */
+export function downloadFilesFromArtifacts(
+ artifacts: readonly KamidsoArtifact[] | undefined,
+): DownloadableFile[] {
+ if (!artifacts?.length) return []
+ const seen = new Set()
+ const files: DownloadableFile[] = []
+ for (const artifact of artifacts) {
+ if (artifact.type !== 'file') continue
+ const parsed = parseDownloadableFile(artifact.content)
+ if (!parsed || seen.has(parsed.url)) continue
+ seen.add(parsed.url)
+ files.push(parsed)
+ }
+ return files
+}
+
+export function mergeDownloadFiles(
+ ...lists: Array
+): DownloadableFile[] {
+ const seen = new Set()
+ const out: DownloadableFile[] = []
+ for (const list of lists) {
+ for (const file of list ?? []) {
+ if (!file?.url || seen.has(file.url)) continue
+ seen.add(file.url)
+ out.push(file)
+ }
+ }
+ return out
+}
+
+/** Seed a synthetic step output list so SessionTurn can render downloads after hydrate. */
+export function seedFileOutputs(
+ artifacts: readonly KamidsoArtifact[] | undefined,
+): Record {
+ const files = downloadFilesFromArtifacts(artifacts)
+ if (!files.length) return {}
+ return {
+ __exports: files.map((file) => ({
+ output_type: 'file',
+ content: { url: file.url, name: file.name },
+ })),
+ }
+}
diff --git a/src/features/assistant/stream/eventReducer.test.ts b/src/features/assistant/stream/eventReducer.test.ts
new file mode 100644
index 0000000..944f797
--- /dev/null
+++ b/src/features/assistant/stream/eventReducer.test.ts
@@ -0,0 +1,222 @@
+import { describe, expect, it } from 'vitest'
+import { initialRunState } from '../state/runState'
+import { runReducer } from './eventReducer'
+
+describe('runReducer', () => {
+ it('handles plan.updated canonical event', () => {
+ const state = initialRunState()
+ const next = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: {
+ type: 'plan.updated',
+ tasks: [{ content: 'بررسی فایل', status: 'in_progress' }],
+ },
+ })
+ expect(next.plan).toHaveLength(1)
+ expect(next.plan[0]?.content).toBe('بررسی فایل')
+ })
+
+ it('handles step.started canonical event', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'RUN_CREATED',
+ runId: 'run_1',
+ viewId: 'v1',
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'step.started', id: 's1', title: 'تحلیل داده', step_type: 'code_execution' },
+ })
+ expect(state.executionByViewId.v1?.steps).toHaveLength(1)
+ expect(state.executionByViewId.v1?.steps[0]?.title).toBe('تحلیل داده')
+ })
+
+ it('collapses hydrated steps that share an id', () => {
+ const state = runReducer(initialRunState(), {
+ type: 'HYDRATE_RUN',
+ run: {
+ id: 'run_1',
+ steps: [
+ { id: 'step-3', title: 'اجرای ابزار', status: 'running' },
+ { id: 'step-3', title: 'تحلیل داده', status: 'running' },
+ ],
+ executionByViewId: {
+ v1: {
+ steps: [
+ { id: 'step-3', title: 'اجرای ابزار', status: 'running' },
+ { id: 'step-3', title: 'تحلیل داده', status: 'running' },
+ ],
+ outputs: {},
+ activeStepId: 'step-3',
+ stepThoughts: {},
+ },
+ },
+ },
+ })
+ expect(state.steps).toHaveLength(1)
+ expect(state.steps[0]?.title).toBe('تحلیل داده')
+ expect(state.executionByViewId.v1?.steps).toHaveLength(1)
+ })
+
+ it('handles question.requested', () => {
+ const next = runReducer(initialRunState(), {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'question.requested', question: 'کدام بازه؟', id: 'q1', run_id: 'run_1' },
+ })
+ expect(next.status).toBe('waiting_for_user')
+ expect(next.question?.question).toBe('کدام بازه؟')
+ })
+
+ it('clears pending question on question.replied', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'question.asked', question: 'کدام دیتابیس؟', id: 'q1', run_id: 'run_1' },
+ })
+ expect(state.question?.id).toBe('q1')
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'question.replied', id: 'q1', run_id: 'run_1' },
+ })
+ expect(state.question).toBeNull()
+ expect(state.status).toBe('running')
+ })
+
+ it('clears pending question on terminal final even without reply event', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'question.asked', question: 'کدام دیتابیس؟', id: 'q1', run_id: 'run_1' },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'answer.completed', content: 'تمام' },
+ })
+ expect(state.question).toBeNull()
+ expect(state.status).toBe('completed')
+ })
+
+ it('handles answer.completed as final', () => {
+ const next = runReducer(initialRunState(), {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'answer.completed', content: 'نتیجه نهایی' },
+ })
+ expect(next.answer).toBe('نتیجه نهایی')
+ expect(next.status).toBe('completed')
+ expect(next.loading).toBe(false)
+ })
+
+ it('streams thinking.delta into per-view highlight text', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'RUN_CREATED',
+ runId: 'run_1',
+ viewId: 'v1',
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.started', id: 's1', round: 1 },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.delta', id: 's1', content: 'حالا ' },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.delta', id: 's1', content: 'شواهد کافی دارم.' },
+ })
+ expect(state.thinkingByViewId.v1).toBe('حالا شواهد کافی دارم.')
+ expect(state.executionByViewId.v1?.stepThoughts.s1).toBe('حالا شواهد کافی دارم.')
+ })
+
+ it('replaces thinking highlight when delta.replace is set', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'RUN_CREATED',
+ runId: 'run_1',
+ viewId: 'v1',
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.delta', id: 's1', content: 'Thought' },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: {
+ type: 'thinking.delta',
+ id: 's1',
+ content: 'the chart series needs field',
+ replace: true,
+ },
+ })
+ expect(state.thinkingByViewId.v1).toBe('the chart series needs field')
+ expect(state.executionByViewId.v1?.stepThoughts.s1).toBe('the chart series needs field')
+ })
+
+ it('ignores ping frames and already-applied sequences', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.delta', sequence: 4, content: 'اول' },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'ping' },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.delta', sequence: 4, content: 'اول' },
+ })
+ expect(state.thinkingByViewId.v1).toBe('اول')
+ expect(state.lastEventSequence).toBe(4)
+ })
+
+ it('keeps thinking text when a run is cancelled', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'RUN_CREATED',
+ runId: 'run_1',
+ viewId: 'v1',
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.delta', content: 'در حال ساخت جدول' },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'run.cancelled' },
+ })
+ expect(state.status).toBe('cancelled')
+ expect(state.loading).toBe(false)
+ expect(state.thinkingByViewId.v1).toBe('در حال ساخت جدول')
+ })
+
+ it('stores html artifacts for the chat iframe', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'RUN_CREATED',
+ runId: 'run_1',
+ viewId: 'v1',
+ })
+ const html = "گزارش
"
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: {
+ type: 'artifact.created',
+ artifact: { id: 'a1', type: 'html', name: 'گزارش', content: html },
+ },
+ })
+ expect(state.htmlByViewId.v1).toBe(html)
+ })
+})
diff --git a/src/features/assistant/stream/eventReducer.ts b/src/features/assistant/stream/eventReducer.ts
new file mode 100644
index 0000000..b69e229
--- /dev/null
+++ b/src/features/assistant/stream/eventReducer.ts
@@ -0,0 +1,385 @@
+import type { KamidsoArtifact, PlanTask } from '../../../contracts/run'
+import { emptyExecution, initialRunState, type ExecutionState, type RunState } from '../state/runState'
+import { normalizeKamidsoEvent } from './normalizeEvent'
+
+export type RunAction =
+ | { type: 'RESET' }
+ | { type: 'RUN_CREATED'; runId: string; viewId: string }
+ | { type: 'SET_LOADING'; loading: boolean }
+ | { type: 'SET_ACTIVE_VIEW'; viewId: string | null }
+ | { type: 'PROCESS_EVENT'; viewId: string; payload: Record }
+ | { type: 'HYDRATE_RUN'; run: Partial }
+ | { type: 'CLEAR_QUESTION' }
+
+export function runReducer(state: RunState, action: RunAction): RunState {
+ switch (action.type) {
+ case 'RESET':
+ return initialRunState()
+ case 'RUN_CREATED':
+ return {
+ ...state,
+ id: action.runId,
+ status: 'running',
+ loading: true,
+ activeViewId: action.viewId,
+ thinkingByViewId: {
+ ...state.thinkingByViewId,
+ [action.viewId]: '',
+ },
+ executionByViewId: {
+ ...state.executionByViewId,
+ [action.viewId]: emptyExecution(),
+ },
+ }
+ case 'SET_LOADING':
+ return { ...state, loading: action.loading }
+ case 'SET_ACTIVE_VIEW':
+ return { ...state, activeViewId: action.viewId }
+ case 'HYDRATE_RUN': {
+ const hydrated = { ...action.run, ...dedupedHydration(action.run) }
+ const patch: Partial = {}
+ for (const [key, value] of Object.entries(hydrated)) {
+ if (value !== undefined) {
+ ;(patch as Record)[key] = value
+ }
+ }
+ return {
+ ...state,
+ ...patch,
+ executionByViewId: {
+ ...state.executionByViewId,
+ ...(patch.executionByViewId ?? {}),
+ },
+ artifacts: patch.artifacts ?? state.artifacts,
+ htmlByViewId: {
+ ...state.htmlByViewId,
+ ...(patch.htmlByViewId ?? {}),
+ },
+ thinkingByViewId: {
+ ...state.thinkingByViewId,
+ ...(patch.thinkingByViewId ?? {}),
+ },
+ }
+ }
+ case 'CLEAR_QUESTION':
+ return { ...state, question: null, status: state.status === 'waiting_for_user' ? 'running' : state.status }
+ case 'PROCESS_EVENT': {
+ if (String(action.payload.type ?? '') === 'ping') return state
+ const event = normalizeKamidsoEvent(action.payload)
+ const seq = Number(event.sequence ?? 0)
+ if (seq > 0 && seq <= state.lastEventSequence) return state
+ const nextSeq = seq > state.lastEventSequence ? seq : state.lastEventSequence
+ return applyEvent(state, action.viewId, event, nextSeq)
+ }
+ default:
+ return state
+ }
+}
+
+function applyEvent(
+ state: RunState,
+ viewId: string,
+ event: Record,
+ lastEventSequence: number,
+): RunState {
+ const type = String(event.type ?? '')
+ let next: RunState = { ...state, lastEventSequence }
+
+ if (type === 'run.created' || type === 'run.started') {
+ return {
+ ...next,
+ id: String(event.run_id ?? state.id ?? ''),
+ status: 'running',
+ }
+ }
+ if (type === 'run.preparing') {
+ return { ...next, status: 'preparing' }
+ }
+ if (type === 'question.asked') {
+ const questions = event.questions as Array> | undefined
+ const first = Array.isArray(questions) ? questions[0] : undefined
+ return {
+ ...next,
+ status: 'waiting_for_user',
+ question: {
+ id: String(event.id ?? event.question_id ?? event.request_id ?? ''),
+ runId: String(event.run_id ?? state.id ?? ''),
+ question: String(
+ event.question ?? event.content ?? first?.question ?? first?.header ?? '',
+ ),
+ options: Array.isArray(event.options)
+ ? (event.options as unknown[]).map((o) => {
+ const opt = o as Record
+ return { id: String(opt.id ?? opt.value ?? ''), label: String(opt.label ?? opt.text ?? '') }
+ })
+ : Array.isArray(first?.options)
+ ? (first.options as unknown[]).map((o) => {
+ const opt = o as Record
+ return { id: String(opt.id ?? opt.value ?? ''), label: String(opt.label ?? opt.text ?? '') }
+ })
+ : undefined,
+ },
+ }
+ }
+ if (type === 'question.replied' || type === 'question.rejected' || type === 'question.resolved') {
+ return { ...next, question: null, status: 'running' }
+ }
+ if (type === 'plan.update' && Array.isArray(event.tasks)) {
+ return { ...next, plan: event.tasks as PlanTask[] }
+ }
+ if (type === 'artifact.created') {
+ const artifact = event.artifact as KamidsoArtifact | undefined
+ if (artifact) {
+ next = { ...next, artifacts: [...next.artifacts, artifact] }
+ if (artifact.type === 'html' && typeof artifact.content === 'string' && artifact.content.trim()) {
+ next = {
+ ...next,
+ htmlByViewId: { ...next.htmlByViewId, [viewId]: artifact.content },
+ }
+ }
+ // Keep file exports on the turn execution so chat download cards survive
+ // active-view switches and don't depend only on the ephemeral panel.
+ if (artifact.type === 'file') {
+ const exec = next.executionByViewId[viewId] ?? emptyExecution()
+ const list = [...(exec.outputs.__exports || [])]
+ list.push({ output_type: 'file', content: artifact.content })
+ next = {
+ ...next,
+ executionByViewId: {
+ ...next.executionByViewId,
+ [viewId]: {
+ ...exec,
+ outputs: { ...exec.outputs, __exports: list },
+ },
+ },
+ }
+ }
+ return next
+ }
+ }
+ if (type === 'answer.delta') {
+ const delta = String(event.content ?? '')
+ return { ...next, answer: next.answer + delta }
+ }
+ if (type === 'thinking.started') {
+ return {
+ ...next,
+ thinkingByViewId: { ...next.thinkingByViewId, [viewId]: '' },
+ }
+ }
+ if (type === 'thinking.delta') {
+ const delta = String(event.content ?? '')
+ const replace = Boolean(event.replace)
+ if (!delta && !replace) return next
+ const prev = next.thinkingByViewId[viewId] ?? ''
+ const nextText = replace ? delta : prev + delta
+ const stepId = event.id ? String(event.id) : null
+ let exec = next.executionByViewId[viewId]
+ if (stepId) {
+ exec = exec ?? emptyExecution()
+ exec = {
+ ...exec,
+ stepThoughts: {
+ ...exec.stepThoughts,
+ [stepId]: nextText,
+ },
+ }
+ next = {
+ ...next,
+ executionByViewId: { ...next.executionByViewId, [viewId]: exec },
+ }
+ }
+ return {
+ ...next,
+ thinkingByViewId: { ...next.thinkingByViewId, [viewId]: nextText },
+ }
+ }
+ if (type === 'final') {
+ const content = String(event.content ?? event.answer ?? next.answer)
+ return {
+ ...next,
+ answer: content,
+ status: 'completed',
+ loading: false,
+ question: null,
+ plan: [],
+ thinkingByViewId: { ...next.thinkingByViewId, [viewId]: '' },
+ executionByViewId: finalizeExecution(next.executionByViewId, viewId),
+ }
+ }
+ if (type === 'run.failed') {
+ const err = (event.error as Record | undefined) ?? event
+ return {
+ ...next,
+ status: 'failed',
+ loading: false,
+ question: null,
+ error: {
+ code: String(err.code ?? 'RUN_FAILED'),
+ message: String(err.message ?? 'اجرای تحلیل ناموفق بود.'),
+ retryable: Boolean(err.retryable),
+ },
+ }
+ }
+ if (type === 'run.cancelled') {
+ return { ...next, status: 'cancelled', loading: false, question: null }
+ }
+ if (type === 'done' || type === 'run.completed') {
+ return { ...next, status: 'completed', loading: false, plan: [], question: null }
+ }
+
+ const exec = next.executionByViewId[viewId] ?? emptyExecution()
+ const updatedExec = applyExecutionEvent(exec, event)
+ if (updatedExec !== exec) {
+ next = {
+ ...next,
+ executionByViewId: { ...next.executionByViewId, [viewId]: updatedExec },
+ steps: updatedExec.steps,
+ }
+ if (type === 'step.chunk') {
+ const outputType = String(event.output_type ?? '')
+ if (outputType === 'html' && typeof event.content === 'string' && event.content.trim()) {
+ next = {
+ ...next,
+ htmlByViewId: {
+ ...next.htmlByViewId,
+ [viewId]: event.content,
+ },
+ }
+ }
+ }
+ }
+ return next
+}
+
+function dedupeSteps(steps: T[]): T[] {
+ const order: string[] = []
+ const latest = new Map()
+ for (const step of steps) {
+ if (!latest.has(step.id)) order.push(step.id)
+ latest.set(step.id, step)
+ }
+ return order.map((id) => latest.get(id) as T)
+}
+
+function dedupedHydration(run: Partial): Partial {
+ const next: Partial = {}
+ if (run.steps) next.steps = dedupeSteps(run.steps)
+ if (run.executionByViewId) {
+ next.executionByViewId = Object.fromEntries(
+ Object.entries(run.executionByViewId).map(([viewId, exec]) => [
+ viewId,
+ { ...exec, steps: dedupeSteps(exec.steps) },
+ ]),
+ )
+ }
+ return next
+}
+
+function finalizeExecution(
+ map: Record,
+ viewId: string,
+): Record {
+ const current = map[viewId]
+ if (!current) return map
+ return {
+ ...map,
+ [viewId]: {
+ ...current,
+ steps: current.steps.map((s) =>
+ s.status === 'running' ? { ...s, status: 'completed' as const } : s,
+ ),
+ },
+ }
+}
+
+function applyExecutionEvent(exec: ExecutionState, event: Record): ExecutionState {
+ const type = String(event.type ?? '')
+ if (type === 'step.start') {
+ const id = String(event.id ?? event.step ?? '')
+ const existingIndex = exec.steps.findIndex((s) => s.id === id)
+ let nextSteps
+ if (existingIndex >= 0) {
+ nextSteps = exec.steps.map((step, idx) =>
+ idx === existingIndex
+ ? {
+ ...step,
+ title: String(event.title ?? step.title),
+ detail: String(event.detail ?? step.detail ?? ''),
+ phase: String(event.phase ?? step.phase ?? ''),
+ status: 'running' as const,
+ }
+ : step.status === 'running'
+ ? { ...step, status: 'completed' as const }
+ : step,
+ )
+ } else {
+ nextSteps = [
+ ...exec.steps.map((item) =>
+ item.status === 'running' ? { ...item, status: 'completed' as const } : item,
+ ),
+ {
+ id,
+ step: Number(event.step ?? exec.steps.length + 1),
+ title: String(event.title ?? ''),
+ detail: String(event.detail ?? ''),
+ phase: String(event.phase ?? ''),
+ status: 'running' as const,
+ action: event.action ? String(event.action) : undefined,
+ },
+ ]
+ }
+ return {
+ ...exec,
+ steps: nextSteps,
+ outputs: { ...exec.outputs, [id]: exec.outputs[id] || [] },
+ activeStepId: existingIndex >= 0 ? id : exec.activeStepId || id,
+ }
+ }
+ if (type === 'step.meta') {
+ if (event.action && String(event.action).toLowerCase() === 'terminate') {
+ const id = String(event.id ?? '')
+ return {
+ ...exec,
+ steps: exec.steps.filter((s) => s.id !== id),
+ activeStepId: exec.activeStepId === id ? null : exec.activeStepId,
+ }
+ }
+ const thought = event.thought != null ? String(event.thought) : ''
+ const id = String(event.id ?? '')
+ return {
+ ...exec,
+ steps: exec.steps.map((item) =>
+ item.id === event.id
+ ? {
+ ...item,
+ title: String(event.title ?? item.title ?? ''),
+ action: event.action ? String(event.action) : item.action,
+ actionInput: event.action_input ?? item.actionInput,
+ }
+ : item,
+ ),
+ stepThoughts: thought && id ? { ...exec.stepThoughts, [id]: thought } : exec.stepThoughts,
+ activeStepId: event.action ? String(event.id) : exec.activeStepId,
+ }
+ }
+ if (type === 'step.chunk') {
+ const id = String(event.id ?? '')
+ const targetId = id || exec.activeStepId
+ if (!targetId) return exec
+ const list = [...(exec.outputs[targetId] || [])]
+ list.push({ output_type: String(event.output_type ?? 'text'), content: event.content })
+ return { ...exec, outputs: { ...exec.outputs, [targetId]: list } }
+ }
+ if (type === 'step.done') {
+ const id = String(event.id ?? '')
+ const targetId = id || exec.activeStepId
+ if (!targetId) return exec
+ const status = (event.status as ExecutionState['steps'][0]['status']) || 'completed'
+ return {
+ ...exec,
+ steps: exec.steps.map((item) => (item.id === targetId ? { ...item, status } : item)),
+ }
+ }
+ return exec
+}
diff --git a/src/features/assistant/stream/normalizeEvent.test.ts b/src/features/assistant/stream/normalizeEvent.test.ts
new file mode 100644
index 0000000..a8cb67a
--- /dev/null
+++ b/src/features/assistant/stream/normalizeEvent.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import { normalizeKamidsoEvent } from './normalizeEvent'
+
+describe('normalizeKamidsoEvent', () => {
+ it('maps step.output to step.chunk with full content', () => {
+ const event = normalizeKamidsoEvent({
+ type: 'step.output',
+ id: 'step-3',
+ output_type: 'code',
+ content: 'import pandas as pd\nprint(1)',
+ workspace: {
+ type: 'step.chunk',
+ id: 'step-3',
+ output_type: 'code',
+ content: 'import pandas as pd\nprint(1)',
+ },
+ })
+ expect(event.type).toBe('step.chunk')
+ expect(event.id).toBe('step-3')
+ expect(event.output_type).toBe('code')
+ expect(event.content).toBe('import pandas as pd\nprint(1)')
+ })
+
+ it('uses tool name from workspace on step.started', () => {
+ const event = normalizeKamidsoEvent({
+ type: 'step.started',
+ id: 'step-2',
+ step_type: 'tool_execution',
+ title: 'تحلیل داده',
+ action: 'code_interpreter',
+ workspace: {
+ type: 'step.start',
+ id: 'step-2',
+ title: 'code_interpreter',
+ detail: 'Thought/Action/Observation',
+ },
+ })
+ expect(event.type).toBe('step.start')
+ expect(event.title).toBe('تحلیل داده')
+ expect(event.action).toBe('code_interpreter')
+ })
+
+ it('prefers action_intention as the display title', () => {
+ const event = normalizeKamidsoEvent({
+ type: 'step.started',
+ id: 'step-2',
+ step_type: 'code_execution',
+ title: 'تحلیل داده',
+ action: 'code_interpreter',
+ action_intention: 'شمارش تیکتها',
+ workspace: {
+ type: 'step.start',
+ id: 'step-2',
+ title: 'code_interpreter',
+ action: 'code_interpreter',
+ },
+ })
+ expect(event.title).toBe('شمارش تیکتها')
+ expect(event.action).toBe('code_interpreter')
+ })
+})
diff --git a/src/features/assistant/stream/normalizeEvent.ts b/src/features/assistant/stream/normalizeEvent.ts
new file mode 100644
index 0000000..2d81142
--- /dev/null
+++ b/src/features/assistant/stream/normalizeEvent.ts
@@ -0,0 +1,89 @@
+/** Normalize backend dialects (canonical + legacy DB-GPT adapter) to internal event types. */
+export function normalizeKamidsoEvent(payload: Record): Record {
+ const type = String(payload.type ?? '')
+ if (type === 'question.requested' || type === 'question.asked') {
+ const questions = payload.questions as Array> | undefined
+ const first = Array.isArray(questions) ? questions[0] : undefined
+ const options = payload.options ?? first?.options
+ return {
+ ...payload,
+ type: 'question.asked',
+ id: payload.id ?? payload.question_id ?? payload.request_id,
+ question_id: payload.question_id ?? payload.request_id ?? payload.id,
+ question: payload.question ?? payload.content ?? first?.question ?? first?.header,
+ options: Array.isArray(options) ? options : undefined,
+ }
+ }
+ if (type === 'step.started') {
+ const ws = (payload.workspace ?? {}) as Record
+ const toolName = String(
+ payload.action ?? ws.action ?? ws.title ?? payload.step_type ?? '',
+ )
+ const intention = String(payload.action_intention ?? ws.action_intention ?? '').trim()
+ return {
+ ...payload,
+ type: 'step.start',
+ id: payload.id ?? ws.id,
+ title: String(intention || payload.title || toolName || ''),
+ detail: String(ws.detail ?? payload.input_summary ?? ''),
+ action: toolName || String(payload.step_type ?? ''),
+ }
+ }
+ if (type === 'step.meta') {
+ const ws = (payload.workspace ?? {}) as Record
+ const intention = String(payload.action_intention ?? ws.action_intention ?? '').trim()
+ return {
+ ...payload,
+ type: 'step.meta',
+ id: payload.id ?? ws.id,
+ title: intention || payload.title || ws.title,
+ action: payload.action ?? ws.action,
+ action_input: payload.action_input ?? ws.action_input,
+ thought: payload.thought ?? ws.thought,
+ }
+ }
+ if (type === 'step.updated') {
+ return {
+ ...payload,
+ type: 'step.meta',
+ content: payload.output_summary ?? payload.content,
+ action: payload.step_type,
+ }
+ }
+ if (type === 'step.output' || type === 'step.chunk') {
+ const ws = (payload.workspace ?? {}) as Record
+ return {
+ ...payload,
+ type: 'step.chunk',
+ id: payload.id ?? ws.id,
+ content: payload.content ?? ws.content ?? payload.output_summary,
+ output_type: payload.output_type ?? ws.output_type ?? 'text',
+ }
+ }
+ if (type === 'step.completed' || type === 'step.failed') {
+ return {
+ ...payload,
+ type: 'step.done',
+ status: type === 'step.failed' ? 'failed' : 'done',
+ }
+ }
+ if (type === 'answer.delta') {
+ return { ...payload, type: 'answer.delta', content: payload.content }
+ }
+ if (type === 'thinking.started' || type === 'thinking.delta') {
+ return {
+ ...payload,
+ type,
+ id: payload.id,
+ content: payload.content,
+ replace: payload.replace,
+ }
+ }
+ if (type === 'answer.completed' || type === 'run.completed') {
+ return { ...payload, type: 'final', content: payload.content ?? payload.answer }
+ }
+ if (type === 'plan.updated') {
+ return { ...payload, type: 'plan.update', tasks: payload.tasks ?? payload.plan }
+ }
+ return payload
+}
diff --git a/src/features/assistant/stream/reconnect.ts b/src/features/assistant/stream/reconnect.ts
new file mode 100644
index 0000000..ea29bbe
--- /dev/null
+++ b/src/features/assistant/stream/reconnect.ts
@@ -0,0 +1,5 @@
+export type ReconnectState = 'idle' | 'reconnecting' | 'connected' | 'failed'
+
+export function backoffMs(attempt: number, base = 1000, max = 15000): number {
+ return Math.min(base * 2 ** attempt, max)
+}
diff --git a/src/features/assistant/stream/useRunStream.ts b/src/features/assistant/stream/useRunStream.ts
new file mode 100644
index 0000000..41cdb0f
--- /dev/null
+++ b/src/features/assistant/stream/useRunStream.ts
@@ -0,0 +1,652 @@
+import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
+import { streamChat, cancelRun } from '../api/chat.api'
+import { getConversationHistory } from '../api/conversations.api'
+import { getRun, streamRunEvents } from '../api/runs.api'
+import { isLiveRunStatus } from '../../../contracts/common'
+import type { KamidsoArtifact } from '../../../contracts/run'
+import { unwrapWorkspaceEvent } from '../lib/events'
+import { cleanFinalContent, decodeFinalEvent } from '../lib/final'
+import type { ChatMessage, ExecutionStep } from '../types/chat'
+import { initialRunState } from '../state/runState'
+import { historyToMessages } from '../adapters/historyToMessages'
+import { runReducer } from './eventReducer'
+import { seedFileOutputs, downloadFilesFromArtifacts } from './downloadFiles'
+
+const ACTIVE_RUN_KEY = 'kamidso.activeRunByConv'
+const STREAM_TERMINAL_TYPES = new Set([
+ 'final',
+ 'answer.completed',
+ 'run.completed',
+ 'run.failed',
+ 'run.cancelled',
+ 'done',
+])
+
+function sleep(ms: number) {
+ return new Promise((resolve) => window.setTimeout(resolve, ms))
+}
+
+function readActiveRunMap(): Record {
+ try {
+ const raw = sessionStorage.getItem(ACTIVE_RUN_KEY)
+ if (!raw) return {}
+ const parsed = JSON.parse(raw) as Record
+ return parsed && typeof parsed === 'object' ? parsed : {}
+ } catch {
+ return {}
+ }
+}
+
+function rememberActiveRun(conversationId: string, runId: string, viewId: string) {
+ if (!conversationId || !runId) return
+ try {
+ const map = readActiveRunMap()
+ map[conversationId] = { runId, viewId }
+ sessionStorage.setItem(ACTIVE_RUN_KEY, JSON.stringify(map))
+ } catch {
+ /* ignore quota / private mode */
+ }
+}
+
+function forgetActiveRun(conversationId: string | null) {
+ if (!conversationId) return
+ try {
+ const map = readActiveRunMap()
+ delete map[conversationId]
+ sessionStorage.setItem(ACTIVE_RUN_KEY, JSON.stringify(map))
+ } catch {
+ /* ignore */
+ }
+}
+
+function peekActiveRun(conversationId: string) {
+ return readActiveRunMap()[conversationId] ?? null
+}
+
+type StreamSendParams = {
+ conversationId: string
+ query: string
+ model?: string
+ skillId?: string
+ reasoningMode?: 'fast' | 'medium' | 'deep'
+ reportMode?: boolean
+ resources?: Array<{ id: string; type: 'file' | 'datasource' | 'knowledge'; name?: string }>
+ humanMsg: ChatMessage
+ viewMsg: ChatMessage
+}
+
+export function useRunStream() {
+ const [state, dispatch] = useReducer(runReducer, undefined, initialRunState)
+ const [messages, setMessages] = useState([])
+ const [conversationId, setConversationId] = useState(null)
+ const abortRef = useRef(null)
+ const activeRunId = useRef(null)
+ const conversationIdRef = useRef(null)
+ const messagesRef = useRef([])
+ const thinkingByViewIdRef = useRef>({})
+ const activeViewIdRef = useRef(null)
+ const lastSendRef = useRef(null)
+ const restoreGen = useRef(0)
+ const streamGen = useRef(0)
+ const lastSeqRef = useRef(0)
+
+ messagesRef.current = messages
+ thinkingByViewIdRef.current = state.thinkingByViewId
+ activeViewIdRef.current = state.activeViewId
+
+ const bindConversation = useCallback((id: string | null) => {
+ conversationIdRef.current = id
+ setConversationId(id)
+ }, [])
+
+ const stop = useCallback(() => {
+ streamGen.current += 1
+ abortRef.current?.abort()
+ abortRef.current = null
+ }, [])
+
+ useEffect(() => {
+ return () => {
+ abortRef.current?.abort()
+ abortRef.current = null
+ }
+ }, [])
+
+ const releaseLiveRun = useCallback(() => {
+ activeRunId.current = null
+ forgetActiveRun(conversationIdRef.current)
+ }, [])
+
+ const markViewStopped = useCallback((viewId: string | null) => {
+ if (!viewId) return
+ const thought = thinkingByViewIdRef.current[viewId] || ''
+ dispatch({
+ type: 'PROCESS_EVENT',
+ viewId,
+ payload: { type: 'run.cancelled' },
+ })
+ setMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === viewId && msg.role === 'view'
+ ? {
+ ...msg,
+ thinking: false,
+ stopped: true,
+ canRetry: true,
+ thought: thought || msg.thought,
+ context: '',
+ }
+ : msg,
+ ),
+ )
+ }, [])
+
+ const cancel = useCallback(async () => {
+ const runId = activeRunId.current
+ const viewId = activeViewIdRef.current
+ if (runId) {
+ await cancelRun(runId)
+ }
+ markViewStopped(viewId)
+ releaseLiveRun()
+ stop()
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }, [markViewStopped, releaseLiveRun, stop])
+
+ const readStream = useCallback(async (response: Response, viewId: string) => {
+ const reader = response.body!.getReader()
+ const decoder = new TextDecoder('utf-8')
+ const thisController = abortRef.current
+ let buffer = ''
+ let reachedTerminal = false
+ try {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+ const parts = buffer.split('\n\n')
+ buffer = parts.pop() || ''
+ for (const part of parts) {
+ const line = part.trim()
+ if (!line.startsWith('data:')) continue
+ const data = line.slice(5).trim()
+ if (!data || data === '[DONE]') continue
+ try {
+ const payload = unwrapWorkspaceEvent(JSON.parse(data) as Record)
+ if (payload.type === 'ping') continue
+ const seq = Number(payload.sequence ?? 0)
+ if (seq > lastSeqRef.current) lastSeqRef.current = seq
+ if (STREAM_TERMINAL_TYPES.has(String(payload.type ?? ''))) {
+ reachedTerminal = true
+ activeRunId.current = null
+ forgetActiveRun(conversationIdRef.current)
+ }
+ if (payload.run_id && !STREAM_TERMINAL_TYPES.has(String(payload.type ?? ''))) {
+ activeRunId.current = String(payload.run_id)
+ rememberActiveRun(conversationIdRef.current || '', String(payload.run_id), viewId)
+ setMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === viewId && msg.role === 'view'
+ ? { ...msg, runId: String(payload.run_id) }
+ : msg,
+ ),
+ )
+ }
+ dispatch({ type: 'PROCESS_EVENT', viewId, payload })
+
+ if (payload.type === 'artifact.created') {
+ const artifact = payload.artifact as KamidsoArtifact | undefined
+ if (artifact?.type === 'file') {
+ const files = downloadFilesFromArtifacts([artifact])
+ if (files.length) {
+ setMessages((prev) =>
+ prev.map((msg) => {
+ if (msg.id !== viewId || msg.role !== 'view') return msg
+ const existing = msg.downloadFiles ?? []
+ const seen = new Set(existing.map((f) => f.url))
+ const merged = [...existing]
+ for (const file of files) {
+ if (!seen.has(file.url)) merged.push(file)
+ }
+ return { ...msg, downloadFiles: merged }
+ }),
+ )
+ }
+ }
+ }
+
+ if (payload.type === 'final' || payload.type === 'answer.completed') {
+ const finalAnswer = decodeFinalEvent(payload)
+ const summaryText = cleanFinalContent(finalAnswer.content)
+ setMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === viewId && msg.role === 'view'
+ ? { ...msg, context: summaryText, citations: finalAnswer.citations, thinking: false }
+ : msg,
+ ),
+ )
+ }
+ if (payload.type === 'plan.update' || payload.type === 'plan.updated') {
+ const tasks = (payload.tasks ?? payload.plan) as ChatMessage['taskPlan']
+ if (Array.isArray(tasks)) {
+ setMessages((prev) =>
+ prev.map((msg) => (msg.id === viewId && msg.role === 'view' ? { ...msg, taskPlan: tasks } : msg)),
+ )
+ }
+ }
+ if (payload.type === 'run.cancelled') {
+ markViewStopped(viewId)
+ }
+ if (payload.type === 'run.failed') {
+ const err = (payload.error as Record | undefined) ?? payload
+ const message = String(err.message ?? 'خطا در دریافت پاسخ. دوباره تلاش کنید.')
+ setMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === viewId && msg.role === 'view' ? { ...msg, context: message, thinking: false } : msg,
+ ),
+ )
+ }
+ } catch {
+ /* ignore malformed */
+ }
+ }
+ }
+ } finally {
+ if (abortRef.current === thisController) {
+ abortRef.current = null
+ }
+ }
+ return reachedTerminal
+ }, [markViewStopped])
+
+ const finishInactiveRun = useCallback((viewId: string, run: { status?: string; answer?: string | null; error?: unknown }) => {
+ if (run.status === 'cancelled') {
+ markViewStopped(viewId)
+ activeRunId.current = null
+ forgetActiveRun(conversationIdRef.current)
+ dispatch({ type: 'SET_LOADING', loading: false })
+ return
+ }
+ const failed = run.status === 'failed'
+ const message = failed
+ ? String((run.error as { message?: string } | undefined)?.message ?? 'خطا در دریافت پاسخ. دوباره تلاش کنید.')
+ : run.answer
+ ? String(run.answer)
+ : undefined
+ setMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === viewId && msg.role === 'view'
+ ? {
+ ...msg,
+ context: message ?? msg.context,
+ thinking: false,
+ }
+ : msg,
+ ),
+ )
+ activeRunId.current = null
+ forgetActiveRun(conversationIdRef.current)
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }, [markViewStopped])
+
+ const recoverLiveStream = useCallback(
+ async (viewId: string, gen: number) => {
+ const runId = activeRunId.current
+ if (!runId) {
+ dispatch({ type: 'SET_LOADING', loading: false })
+ return
+ }
+ let delay = 400
+ while (gen === streamGen.current) {
+ await sleep(delay)
+ if (gen !== streamGen.current) return
+ try {
+ const run = await getRun(runId)
+ if (gen !== streamGen.current) return
+ if (!isLiveRunStatus(run.status)) {
+ finishInactiveRun(viewId, run)
+ return
+ }
+ dispatch({ type: 'SET_LOADING', loading: true })
+ const controller = new AbortController()
+ abortRef.current = controller
+ const response = await streamRunEvents(runId, lastSeqRef.current, controller.signal)
+ const terminal = await readStream(response, viewId)
+ if (terminal || controller.signal.aborted || gen !== streamGen.current) return
+ delay = 400
+ } catch (err) {
+ if ((err as Error).name === 'AbortError' || gen !== streamGen.current) return
+ delay = Math.min(Math.round(delay * 1.5), 5000)
+ }
+ }
+ },
+ [finishInactiveRun, readStream],
+ )
+
+ const hydrateRun = useCallback(
+ async (runId: string, viewId: string, options?: { live?: boolean; setActive?: boolean }) => {
+ const setActive = options?.setActive !== false
+ try {
+ const run = await getRun(runId)
+ if (setActive) activeRunId.current = runId
+ const live = options?.live ?? isLiveRunStatus(run.status)
+ const steps = (run.steps ?? []).map((step, index) => ({
+ id: step.id,
+ step: step.step ?? index + 1,
+ title: step.title,
+ status: (step.status === 'done' ? 'done' : step.status) as ExecutionStep['status'],
+ detail: step.detail,
+ phase: step.phase,
+ action: step.action,
+ }))
+ const running = [...steps].reverse().find((item) => item.status === 'running')
+ const artifacts = (run.artifacts ?? []) as KamidsoArtifact[]
+ const seededOutputs = seedFileOutputs(artifacts)
+ const downloadFiles = downloadFilesFromArtifacts(artifacts)
+ dispatch({
+ type: 'HYDRATE_RUN',
+ run: {
+ id: setActive ? runId : undefined,
+ status: setActive ? run.status : undefined,
+ answer: setActive ? run.answer ?? '' : undefined,
+ plan: setActive ? [] : undefined,
+ steps: setActive ? steps : undefined,
+ artifacts: setActive ? artifacts : undefined,
+ activeViewId: setActive ? viewId : undefined,
+ loading: setActive ? live : undefined,
+ lastEventSequence: setActive ? 0 : undefined,
+ executionByViewId: {
+ [viewId]: {
+ steps,
+ outputs: seededOutputs,
+ activeStepId: running?.id ?? steps[steps.length - 1]?.id ?? null,
+ stepThoughts: {},
+ },
+ },
+ },
+ })
+ setMessages((prev) =>
+ prev.map((msg) => {
+ if (msg.id !== viewId || msg.role !== 'view') return msg
+ return {
+ ...msg,
+ runId,
+ downloadFiles: downloadFiles.length
+ ? downloadFiles
+ : msg.downloadFiles,
+ context:
+ setActive && run.answer
+ ? run.answer
+ : msg.context,
+ thinking: setActive ? (live && !run.answer ? true : false) : msg.thinking,
+ }
+ }),
+ )
+ if (setActive) {
+ lastSeqRef.current = 0
+ const gen = streamGen.current
+ if (live) {
+ rememberActiveRun(conversationIdRef.current || '', runId, viewId)
+ dispatch({ type: 'SET_LOADING', loading: true })
+ const controller = new AbortController()
+ abortRef.current = controller
+ try {
+ const response = await streamRunEvents(runId, 0, controller.signal)
+ const terminal = await readStream(response, viewId)
+ if (!terminal && !controller.signal.aborted && gen === streamGen.current) {
+ await recoverLiveStream(viewId, gen)
+ } else if (gen === streamGen.current) {
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }
+ } catch (err) {
+ if ((err as Error).name === 'AbortError' || gen !== streamGen.current) return
+ await recoverLiveStream(viewId, gen)
+ }
+ } else {
+ if (setActive) activeRunId.current = null
+ forgetActiveRun(conversationIdRef.current)
+ const controller = new AbortController()
+ abortRef.current = controller
+ try {
+ const response = await streamRunEvents(runId, 0, controller.signal)
+ await readStream(response, viewId)
+ } catch {
+ /* replay best-effort */
+ }
+ if (gen === streamGen.current) {
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }
+ }
+ } else if (!live && downloadFiles.length === 0) {
+ try {
+ const response = await streamRunEvents(runId, 0)
+ await readStream(response, viewId)
+ } catch {
+ /* keep seeded/empty state */
+ }
+ }
+ } catch (err) {
+ if (setActive && (err as Error).name !== 'AbortError') {
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }
+ }
+ },
+ [readStream, recoverLiveStream],
+ )
+
+ const restoreConversation = useCallback(
+ async (nextConversationId: string) => {
+ if (
+ conversationIdRef.current === nextConversationId &&
+ activeRunId.current &&
+ abortRef.current
+ ) {
+ bindConversation(nextConversationId)
+ return
+ }
+
+ const gen = ++restoreGen.current
+ stop()
+ bindConversation(nextConversationId)
+ dispatch({ type: 'RESET' })
+ setMessages([])
+ activeRunId.current = null
+
+ try {
+ const data = await getConversationHistory(nextConversationId)
+ if (gen !== restoreGen.current) return
+
+ const stored = peekActiveRun(nextConversationId)
+ const latestRun = data.latest_run
+ ? {
+ id: data.latest_run.id,
+ status: data.latest_run.status,
+ answer: data.latest_run.answer,
+ resources: data.latest_run.resources,
+ }
+ : stored
+ ? { id: stored.runId, status: 'running' }
+ : null
+ const mapped = historyToMessages(data.messages, latestRun)
+ setMessages(mapped)
+
+ const runId = data.active_run_id || stored?.runId || data.latest_run?.id
+ const lastView = [...mapped].reverse().find((msg) => msg.role === 'view')
+
+ // Restore download links for every prior turn that has a run_id.
+ const priorViews = mapped.filter(
+ (msg) => msg.role === 'view' && msg.runId && msg.id !== lastView?.id,
+ )
+ for (const view of priorViews) {
+ if (gen !== restoreGen.current || !view.runId) break
+ await hydrateRun(view.runId, view.id, { live: false, setActive: false })
+ }
+
+ if (runId && lastView) {
+ await hydrateRun(runId, lastView.id, {
+ live: isLiveRunStatus(latestRun?.status || data.latest_run?.status || ''),
+ setActive: true,
+ })
+ }
+ } catch {
+ if (gen === restoreGen.current) {
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }
+ }
+ },
+ [bindConversation, hydrateRun, stop],
+ )
+
+ const startStream = useCallback(
+ async (params: StreamSendParams) => {
+ lastSendRef.current = params
+ lastSeqRef.current = 0
+ const gen = streamGen.current
+ bindConversation(params.conversationId)
+ dispatch({ type: 'RUN_CREATED', runId: '', viewId: params.viewMsg.id })
+ dispatch({ type: 'SET_LOADING', loading: true })
+
+ const controller = new AbortController()
+ abortRef.current = controller
+
+ try {
+ const response = await streamChat(
+ {
+ conversation_id: params.conversationId,
+ message: params.query,
+ model_id: params.model,
+ skill_id: params.skillId,
+ reasoning_mode: params.reasoningMode || 'fast',
+ report_mode: Boolean(params.reportMode),
+ resources: params.resources,
+ mode: 'auto',
+ temperature: 0.2,
+ max_new_tokens: 65536,
+ },
+ controller.signal,
+ )
+ const terminal = await readStream(response, params.viewMsg.id)
+ if (!terminal && !controller.signal.aborted && gen === streamGen.current) {
+ await recoverLiveStream(params.viewMsg.id, gen)
+ } else if (gen === streamGen.current) {
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }
+ } catch (err) {
+ if ((err as Error).name === 'AbortError' || gen !== streamGen.current) return
+ if (activeRunId.current) {
+ await recoverLiveStream(params.viewMsg.id, gen)
+ return
+ }
+ setMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === params.viewMsg.id
+ ? { ...msg, context: 'خطا در دریافت پاسخ. دوباره تلاش کنید.', thinking: false }
+ : msg,
+ ),
+ )
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }
+ },
+ [bindConversation, readStream, recoverLiveStream],
+ )
+
+ const send = useCallback(
+ async (params: StreamSendParams) => {
+ if (state.loading || abortRef.current) {
+ return
+ }
+ stop()
+ setMessages((prev) => [
+ ...prev.map((msg) =>
+ msg.role === 'view' && msg.stopped ? { ...msg, canRetry: false } : msg,
+ ),
+ params.humanMsg,
+ params.viewMsg,
+ ])
+ await startStream(params)
+ },
+ [startStream, state.loading, stop],
+ )
+
+ const retryStopped = useCallback(async () => {
+ if (state.loading || abortRef.current) return
+ stop()
+ const msgs = messagesRef.current
+ let viewIndex = -1
+ for (let i = msgs.length - 1; i >= 0; i -= 1) {
+ const msg = msgs[i]
+ if (msg.role === 'view' && msg.stopped && msg.canRetry) {
+ viewIndex = i
+ break
+ }
+ }
+ if (viewIndex < 1) return
+ const viewMsg = msgs[viewIndex]
+ const humanMsg = msgs[viewIndex - 1]
+ if (humanMsg.role !== 'human') return
+ const previous = lastSendRef.current
+ const conversationId = previous?.conversationId || conversationIdRef.current
+ if (!conversationId) return
+ const nextView: ChatMessage = {
+ ...viewMsg,
+ context: '',
+ thinking: true,
+ stopped: false,
+ canRetry: false,
+ thought: undefined,
+ }
+ setMessages((prev) => prev.map((msg) => (msg.id === viewMsg.id ? nextView : msg)))
+ await startStream({
+ conversationId,
+ query: previous?.query || humanMsg.context,
+ model: previous?.model,
+ skillId: previous?.skillId || humanMsg.attachedSkill?.id,
+ reasoningMode: previous?.reasoningMode,
+ reportMode: previous?.reportMode,
+ resources: previous?.resources,
+ humanMsg,
+ viewMsg: nextView,
+ })
+ }, [startStream, state.loading, stop])
+
+ const resetConversation = useCallback(() => {
+ restoreGen.current += 1
+ stop()
+ forgetActiveRun(conversationIdRef.current)
+ dispatch({ type: 'RESET' })
+ setMessages([])
+ activeRunId.current = null
+ bindConversation(null)
+ }, [bindConversation, stop])
+
+ const setPendingQuestion = useCallback((q: typeof state.question) => {
+ if (!q) dispatch({ type: 'CLEAR_QUESTION' })
+ }, [])
+
+ return {
+ messages,
+ setMessages,
+ conversationId,
+ executionMap: state.executionByViewId,
+ loading: state.loading,
+ taskPlan: state.plan,
+ pendingQuestion: state.question,
+ setPendingQuestion,
+ activeViewId: state.activeViewId,
+ setActiveViewId: (id: string | null) => dispatch({ type: 'SET_ACTIVE_VIEW', viewId: id }),
+ runStatus: state.status,
+ runId: state.id ?? activeRunId.current,
+ artifacts: state.artifacts,
+ htmlByViewId: state.htmlByViewId,
+ thinkingByViewId: state.thinkingByViewId,
+ answer: state.answer,
+ error: state.error,
+ send,
+ retryStopped,
+ stop,
+ cancel,
+ hydrateRun,
+ restoreConversation,
+ resetConversation,
+ }
+}
diff --git a/src/features/assistant/types/chat.ts b/src/features/assistant/types/chat.ts
new file mode 100644
index 0000000..ed26476
--- /dev/null
+++ b/src/features/assistant/types/chat.ts
@@ -0,0 +1,83 @@
+import type { SessionFileSnapshot } from '../session-files'
+import type { AgentCitation } from '../lib/final'
+
+export type TaskItem = {
+ content: string
+ status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
+ priority?: 'high' | 'medium' | 'low'
+}
+
+export type ExecutionStep = {
+ id: string
+ step?: number
+ title?: string
+ detail?: string
+ phase?: string
+ status: 'running' | 'done' | 'failed' | 'pending' | 'completed'
+ action?: string
+ actionInput?: unknown
+ todoMeta?: { state?: string; done?: number; total?: number }
+}
+
+export type ExecutionOutput = {
+ output_type: string
+ content: unknown
+}
+
+export type ExecutionState = {
+ steps: ExecutionStep[]
+ outputs: Record
+ activeStepId: string | null
+ stepThoughts: Record
+}
+
+export type ChatMessage = {
+ id: string
+ role: 'human' | 'view'
+ context: string
+ order?: number
+ thinking?: boolean
+ stopped?: boolean
+ canRetry?: boolean
+ thought?: string
+ runId?: string
+ /** Persisted downloadable exports for this turn (Excel/CSV). Survives refresh. */
+ downloadFiles?: Array<{ url: string; name: string }>
+ attachedFiles?: readonly SessionFileSnapshot[]
+ attachedDb?: { db_name: string; db_type: string }
+ attachedDatabases?: readonly { id?: string; db_name: string; db_type: string }[]
+ attachedSkill?: { name: string; id: string }
+ attachedKnowledge?: { name: string; id: string }
+ taskPlan?: TaskItem[]
+ citations?: AgentCitation[]
+}
+
+export type PendingQuestion = {
+ id: string
+ runId?: string
+ question: string
+ options?: Array<{ id: string; label: string }>
+}
+
+export type ArtifactItem = {
+ id: string
+ type: 'file' | 'table' | 'chart' | 'image' | 'code' | 'markdown' | 'html' | 'report'
+ name: string
+ content: unknown
+ filePath?: string
+}
+
+import type { SettingsSummary } from '../../settings/types'
+
+export type ResourceOption = {
+ id: string
+ name: string
+ description?: string
+ type?: string
+ nameEn?: string
+ category?: string
+ categoryId?: string
+ domain?: string
+ icon?: string
+ settings?: SettingsSummary
+}
diff --git a/src/features/assistant/types/viewModels.ts b/src/features/assistant/types/viewModels.ts
new file mode 100644
index 0000000..8de10dc
--- /dev/null
+++ b/src/features/assistant/types/viewModels.ts
@@ -0,0 +1,33 @@
+import type { KamidsoArtifact, PlanTask } from '../../../contracts/run'
+import type { WorkspaceFileSnapshot } from '../../../contracts/file'
+
+export type AgentTurnView = {
+ id: string
+ userMessage: string
+ assistantMessage?: string
+ taskPlan?: PlanTask[]
+ attachedFiles?: readonly WorkspaceFileSnapshot[]
+ isWorking?: boolean
+ thinkingContent?: string
+}
+
+export type ExecutionStepView = {
+ id: string
+ title: string
+ status: 'pending' | 'running' | 'completed' | 'failed'
+ detail?: string
+ toolLabel?: string
+}
+
+export type AttachmentView = {
+ id: string
+ name: string
+ sizeLabel: string
+ status: string
+ progress?: number
+ failed?: boolean
+}
+
+export type ArtifactView = KamidsoArtifact
+
+export type PlanView = PlanTask[]
diff --git a/src/features/assistant/workspace/ChatMessageList.test.tsx b/src/features/assistant/workspace/ChatMessageList.test.tsx
new file mode 100644
index 0000000..18e3371
--- /dev/null
+++ b/src/features/assistant/workspace/ChatMessageList.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { ChatMessageList } from './ChatMessageList'
+
+describe('ChatMessageList', () => {
+ it('shows the live work plan in the in-chat process card while loading', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('در حال اجرای برنامه کار')).toBeInTheDocument()
+ expect(screen.getByText('بررسی ساختار و واحد ارز فایل')).toBeInTheDocument()
+ expect(screen.getByText('محاسبه مقادیر مشاهدهشده ارزش افزوده با Python')).toBeInTheDocument()
+ expect(screen.getByText('تولید گزارش نتیجه با html_interpreter')).toBeInTheDocument()
+ expect(screen.getByText('1/3')).toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/workspace/ChatMessageList.tsx b/src/features/assistant/workspace/ChatMessageList.tsx
new file mode 100644
index 0000000..cc164c8
--- /dev/null
+++ b/src/features/assistant/workspace/ChatMessageList.tsx
@@ -0,0 +1,141 @@
+import { ArrowDown } from 'lucide-react'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { resolveTurnHtml } from '../report/extractReport'
+import type { ChatMessage, ExecutionState, TaskItem } from '../types/chat'
+import type { SessionFileSnapshot } from '../session-files/types'
+import { SessionTurn } from './SessionTurn'
+import { conversationWidthClass } from './conversationLayout'
+import {
+ downloadFilesFromExecution,
+ mergeDownloadFiles,
+} from '../stream/downloadFiles'
+
+export function ChatMessageList({
+ rounds,
+ loading,
+ executionMap,
+ activeViewId,
+ selectedStepId,
+ htmlByViewId,
+ thinkingByViewId,
+ liveTaskPlan,
+ hideTurnAttachments,
+ onOpenExecution,
+ onRetryStopped,
+ onPreviewFile,
+}: {
+ rounds: Array<{ human: ChatMessage; view?: ChatMessage }>
+ loading?: boolean
+ executionMap?: Record
+ activeViewId?: string | null
+ selectedStepId?: string | null
+ htmlByViewId?: Record
+ thinkingByViewId?: Record
+ liveTaskPlan?: TaskItem[]
+ hideTurnAttachments?: boolean
+ onOpenExecution?: (viewId: string, stepId?: string) => void
+ onRetryStopped?: () => void
+ onPreviewFile?: (file: SessionFileSnapshot) => void
+}) {
+ const containerRef = useRef(null)
+ const bottomRef = useRef(null)
+ const [showScrollButton, setShowScrollButton] = useState(false)
+
+ const scrollToBottom = useCallback(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
+ }, [])
+
+ useEffect(() => {
+ if (loading) scrollToBottom()
+ }, [loading, rounds.length, scrollToBottom, thinkingByViewId, activeViewId])
+
+ return (
+
+
{
+ const el = containerRef.current
+ if (!el) return
+ const distance = el.scrollHeight - el.scrollTop - el.clientHeight
+ setShowScrollButton(distance > 120)
+ }}
+ >
+
+ {rounds.map(({ human, view }, index) => {
+ const viewId = view?.id
+ const execution = viewId ? executionMap?.[viewId] : undefined
+ const isActiveTurn = Boolean(viewId && viewId === activeViewId)
+ const steps = execution?.steps ?? []
+ const activeStepId = isActiveTurn ? selectedStepId ?? execution?.activeStepId : undefined
+ const lastStepId = activeStepId ?? steps[steps.length - 1]?.id
+ const htmlReport = resolveTurnHtml({
+ viewId,
+ execution,
+ htmlByViewId,
+ })
+ const thinkingContent =
+ view?.thought?.trim() ||
+ (isActiveTurn ? thinkingByViewId?.[viewId ?? '']?.trim() || undefined : undefined)
+ const reportLoading = Boolean(
+ isActiveTurn && loading && !htmlReport && !view?.context?.trim() && !thinkingContent && !view?.stopped,
+ )
+ const isWorking = Boolean(
+ !view?.stopped && (view?.thinking || (isActiveTurn && loading && !view?.context?.trim())),
+ )
+ const canRetry = Boolean(view?.stopped && view?.canRetry && index === rounds.length - 1 && !loading)
+ const taskPlan =
+ isActiveTurn && liveTaskPlan?.length
+ ? liveTaskPlan
+ : view?.taskPlan?.length
+ ? view.taskPlan
+ : undefined
+ const downloadFiles = mergeDownloadFiles(
+ view?.downloadFiles,
+ downloadFilesFromExecution(execution),
+ )
+
+ return (
+
onOpenExecution(viewId, lastStepId)
+ : undefined
+ }
+ onRetry={canRetry ? onRetryStopped : undefined}
+ onPreviewFile={onPreviewFile}
+ />
+ )
+ })}
+
+
+
+ {showScrollButton ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/workspace/ChatWelcome.tsx b/src/features/assistant/workspace/ChatWelcome.tsx
new file mode 100644
index 0000000..19a1dbb
--- /dev/null
+++ b/src/features/assistant/workspace/ChatWelcome.tsx
@@ -0,0 +1,92 @@
+import { Database, FileSpreadsheet, Sparkles, Zap } from 'lucide-react'
+import { t } from '../../../i18n'
+
+const suggestions = [
+ {
+ icon: FileSpreadsheet,
+ title: 'تحلیل فایل',
+ description: 'Excel و CSV را بهعنوان فایل داده بارگذاری کنید و سؤال بپرسید',
+ preset: 'خلاصهای از دادههای فایل بده',
+ tone: 'bg-accent-soft text-accent',
+ },
+ {
+ icon: Database,
+ title: 'پرسوجوی داده',
+ description: 'به پایگاه داده وصل شوید و تحلیل کنید',
+ preset: 'ده مشتری برتر را نشان بده',
+ tone: 'bg-success-soft text-success',
+ },
+ {
+ icon: Sparkles,
+ title: 'دانش سازمان',
+ description: 'از اسناد و سیاستها پاسخ بگیرید',
+ tone: 'bg-warn-soft text-warn',
+ },
+ {
+ icon: Zap,
+ title: 'تحلیل سریع',
+ description: 'سؤال کسبوکار را مستقیم بپرسید',
+ tone: 'bg-info-soft text-info',
+ },
+]
+
+export function ChatWelcome({
+ onSuggestion,
+ className = '',
+}: {
+ onSuggestion?: (preset: string) => void
+ className?: string
+}) {
+ return (
+
+
+
+ K
+
+
+ کامیدسو · kamidso
+
+
+ {t('assistant.welcome.title')}
+
+
+ {t('assistant.welcome.subtitle')}
+
+
+ {suggestions.map((item, index) => {
+ const Icon = item.icon
+ return (
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/src/features/assistant/workspace/ConversationContextBar.test.tsx b/src/features/assistant/workspace/ConversationContextBar.test.tsx
new file mode 100644
index 0000000..0c57c60
--- /dev/null
+++ b/src/features/assistant/workspace/ConversationContextBar.test.tsx
@@ -0,0 +1,71 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { ConversationContextBar } from './ConversationContextBar'
+
+describe('ConversationContextBar', () => {
+ it('renders pinned files and database for the whole conversation', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/منابع این گفتگو/)).toBeInTheDocument()
+ expect(screen.getByText('Book1 (1).xlsx')).toBeInTheDocument()
+ expect(screen.getByText('sales-db')).toBeInTheDocument()
+ })
+
+ it('can remove a pinned file', () => {
+ const onRemoveFile = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'حذف' }))
+ expect(onRemoveFile).toHaveBeenCalledWith('file_book1')
+ })
+
+ it('collapses a long source list and expands on demand', () => {
+ const files = Array.from({ length: 8 }, (_, index) => ({
+ file_id: `file_${index}`,
+ name: `sheet-${index}.xlsx`,
+ size: 1024,
+ media_type: 'application/xlsx',
+ kind: 'excel' as const,
+ status: 'ready' as const,
+ ordinal: index,
+ }))
+ render()
+
+ expect(screen.getByText(/منابع این گفتگو/)).toBeInTheDocument()
+ expect(screen.queryByText('sheet-7.xlsx')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /بزرگکردن/ }))
+ expect(screen.getByText('sheet-7.xlsx')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /کوچککردن/ }))
+ expect(screen.queryByText('sheet-7.xlsx')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/workspace/ConversationContextBar.tsx b/src/features/assistant/workspace/ConversationContextBar.tsx
new file mode 100644
index 0000000..0d9e65b
--- /dev/null
+++ b/src/features/assistant/workspace/ConversationContextBar.tsx
@@ -0,0 +1,185 @@
+import { useState } from 'react'
+import { BookOpen, ChevronDown, Database, FileSpreadsheet, FileText, Image as ImageIcon, X } from 'lucide-react'
+import { t } from '../../../i18n'
+import type { SessionFileSnapshot } from '../session-files/types'
+import type { ChatMessage } from '../types/chat'
+
+const COLLAPSE_AFTER = 6
+
+function fileIcon(name: string) {
+ const ext = name.split('.').pop()?.toLowerCase() ?? ''
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return ImageIcon
+ if (['csv', 'xlsx', 'xls'].includes(ext)) return FileSpreadsheet
+ return FileText
+}
+
+function formatBytes(bytes: number) {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+function fileTone(name: string) {
+ const ext = name.split('.').pop()?.toLowerCase() ?? ''
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) {
+ return 'bg-violet-50 text-violet-600'
+ }
+ if (['csv', 'xlsx', 'xls'].includes(ext)) {
+ return 'bg-emerald-50 text-emerald-700'
+ }
+ return 'bg-slate-100 text-slate-600'
+}
+
+function PinChip({
+ icon: Icon,
+ label,
+ hint,
+ tone,
+ onRemove,
+ onClick,
+}: {
+ icon: typeof BookOpen
+ label: string
+ hint?: string
+ tone: string
+ onRemove?: () => void
+ onClick?: () => void
+}) {
+ const body = (
+ <>
+
+
+
+
+ {label}
+ {hint ? {hint} : null}
+
+ >
+ )
+
+ return (
+
+ {onClick ? (
+
+ ) : (
+ {body}
+ )}
+ {onRemove ? (
+
+ ) : null}
+
+ )
+}
+
+export function ConversationContextBar({
+ files,
+ knowledge,
+ databases = [],
+ onPreviewFile,
+ onRemoveFile,
+ onRemoveDatabase,
+ onClearKnowledge,
+}: {
+ files?: readonly SessionFileSnapshot[]
+ knowledge?: ChatMessage['attachedKnowledge']
+ databases?: readonly NonNullable[number][]
+ onPreviewFile?: (file: SessionFileSnapshot) => void
+ onRemoveFile?: (fileId: string) => void
+ onRemoveDatabase?: (key: string) => void
+ onClearKnowledge?: () => void
+}) {
+ const fileList = files ?? []
+ const hasFiles = fileList.length > 0
+ const hasKnowledge = Boolean(knowledge?.name)
+ const hasDb = databases.length > 0
+ const sourceCount = fileList.length + (hasKnowledge ? 1 : 0) + databases.length
+ const [expanded, setExpanded] = useState(sourceCount <= COLLAPSE_AFTER)
+ if (!hasFiles && !hasKnowledge && !hasDb) return null
+
+ const canToggle = sourceCount > 4
+ const showChips = expanded || !canToggle
+ const previewNames = [
+ ...(hasKnowledge && knowledge?.name ? [knowledge.name] : []),
+ ...databases.map((item) => item.db_name),
+ ...fileList.map((file) => file.name),
+ ]
+ const preview = previewNames.slice(0, 3).join('، ')
+ const extra = Math.max(0, sourceCount - 3)
+
+ return (
+
+
+
+ {t('assistant.chat.conversationSources')}
+ {sourceCount > 1 ? ` · ${sourceCount}` : ''}
+
+ {canToggle ? (
+
+ ) : (
+
{t('assistant.chat.conversationSourcesHint')}
+ )}
+
+ {showChips ? (
+
+ {hasKnowledge ? (
+
+ ) : null}
+ {databases.map((item) => (
+
onRemoveDatabase(item.id || item.db_name) : undefined
+ }
+ />
+ ))}
+ {fileList.map((file) => {
+ const Icon = fileIcon(file.name)
+ return (
+ 0 ? formatBytes(file.size) : t('assistant.composer.files')}
+ tone={fileTone(file.name)}
+ onClick={onPreviewFile ? () => onPreviewFile(file) : undefined}
+ onRemove={onRemoveFile ? () => onRemoveFile(file.file_id) : undefined}
+ />
+ )
+ })}
+
+ ) : (
+
+ {preview}
+ {extra > 0 ? ` +${extra}` : ''}
+
+ )}
+
+ )
+}
diff --git a/src/features/assistant/workspace/SessionTurn.test.tsx b/src/features/assistant/workspace/SessionTurn.test.tsx
new file mode 100644
index 0000000..ec969c2
--- /dev/null
+++ b/src/features/assistant/workspace/SessionTurn.test.tsx
@@ -0,0 +1,209 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { SessionTurn } from './SessionTurn'
+
+describe('SessionTurn', () => {
+ it('renders user bubble and assistant answer without execution timeline', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('سلام')).toBeInTheDocument()
+ expect(screen.getByText('پاسخ تست')).toBeInTheDocument()
+ expect(screen.queryByText('مرحله ۱')).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'مشاهده اجرا · 2' })).toBeInTheDocument()
+ })
+
+ it('shows a stopped thinking card with retry', () => {
+ const onRetry = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.getByText('متوقف شد')).toBeInTheDocument()
+ expect(screen.getByText(/در حال ساخت جدول کاربران/)).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /تلاش مجدد/ }))
+ expect(onRetry).toHaveBeenCalledOnce()
+ })
+
+ it('opens execution panel via thinking card callback', () => {
+ const onOpenExecution = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.getByText('در حال فکر کردن…')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /در حال فکر کردن/ }))
+ expect(onOpenExecution).toHaveBeenCalledOnce()
+ })
+
+ it('shows live thinking stream inside the process card', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('در حال فکر کردن…')).toBeInTheDocument()
+ expect(screen.getByText(/فروش خالص اسفند صفر است/)).toBeInTheDocument()
+ })
+
+ it('keeps working status when a mid-stream report arrives', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('در حال تکمیل تحلیل…')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '1/2' })).toBeInTheDocument()
+ })
+
+ it('shows the work plan inside the in-chat process card while loading', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('در حال اجرای برنامه کار')).toBeInTheDocument()
+ expect(screen.getByText('بررسی ساختار و واحد ارز فایل')).toBeInTheDocument()
+ expect(screen.getByText('محاسبه مقادیر مشاهدهشده ارزش افزوده با Python')).toBeInTheDocument()
+ expect(screen.getByText('تولید گزارش نتیجه با html_interpreter')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /در حال اجرای برنامه کار/ })).toBeInTheDocument()
+ expect(screen.getByText('1/3')).toBeInTheDocument()
+ })
+
+ it('copies question and answer text', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.assign(navigator, { clipboard: { writeText } })
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'کپی پرسش' }))
+ expect(writeText).toHaveBeenCalledWith('این فایل در مورد چیه')
+
+ fireEvent.click(screen.getByRole('button', { name: 'کپی پاسخ' }))
+ expect(writeText).toHaveBeenCalledWith('یک دفتر مالی است.')
+ })
+
+ it('shows attached knowledge on the user bubble', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('ویراگلد چیست؟')).toBeInTheDocument()
+ expect(screen.getByText('پاسخ فنی واحد معاملات')).toBeInTheDocument()
+ expect(screen.getByText('فضای دانش')).toBeInTheDocument()
+ })
+
+ it('hides attachments when the conversation bar already shows them', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('فروش را تفکیک کن')).toBeInTheDocument()
+ expect(screen.queryByText('Book1 (1).xlsx')).not.toBeInTheDocument()
+ })
+
+ it('shows a full chat analysis together with the HTML report', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/تاریخ تحلیل: امروز/)).toBeInTheDocument()
+ expect(screen.getByText(/خلاصه اجرایی مستقل/)).toBeInTheDocument()
+ expect(screen.getByTitle('html-preview')).toBeInTheDocument()
+ })
+
+ it('hints when a chat answer stops on heading 1', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/پاسخ اینجا قطع شده/)).toBeInTheDocument()
+ })
+
+ it('still hints when truncated text is shown together with an HTML report', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/پاسخ اینجا قطع شده/)).toBeInTheDocument()
+ expect(screen.getByTitle('html-preview')).toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/workspace/SessionTurn.tsx b/src/features/assistant/workspace/SessionTurn.tsx
new file mode 100644
index 0000000..07932d3
--- /dev/null
+++ b/src/features/assistant/workspace/SessionTurn.tsx
@@ -0,0 +1,428 @@
+import { useEffect, useRef, useState } from 'react'
+import { BookOpen, Check, ChevronLeft, Copy, Database, RotateCcw, Sparkles, Square } from 'lucide-react'
+import AttachmentMessageCards from '../session-files/AttachmentMessageCards'
+import type { SessionFileSnapshot } from '../session-files/types'
+import type { ChatMessage, ExecutionStep, TaskItem } from '../types/chat'
+import { MarkdownAnswer } from '../components/MarkdownAnswer'
+import { HtmlPreview } from '../components/HtmlPreview'
+import { cleanFinalContent, looksTruncated } from '../lib/final'
+import { TaskPlanList, visibleTasks } from '../components/TaskPlanCard'
+import {
+ FileDownloadCard,
+ type DownloadableFile,
+} from '../artifacts/FileDownloadCard'
+import { t } from '../../../i18n'
+
+function CopyButton({ text, label }: { text: string; label: string }) {
+ const [copied, setCopied] = useState(false)
+
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(text)
+ setCopied(true)
+ window.setTimeout(() => setCopied(false), 1600)
+ } catch {
+ /* clipboard can be unavailable in some browsers */
+ }
+ }
+
+ const copiedLabel = t('assistant.chat.copied')
+
+ return (
+
+ )
+}
+
+function ThinkingCard({
+ steps,
+ tasks,
+ streamText,
+ stopped = false,
+ canRetry = false,
+ onOpen,
+ onRetry,
+}: {
+ steps: ExecutionStep[]
+ tasks?: TaskItem[]
+ streamText?: string
+ stopped?: boolean
+ canRetry?: boolean
+ onOpen?: () => void
+ onRetry?: () => void
+}) {
+ const plan = visibleTasks(tasks ?? [])
+ const planDone = plan.filter((task) => task.status === 'completed').length
+ const completed = steps.filter((step) => step.status === 'done' || step.status === 'completed').length
+ const current = [...steps].reverse().find((step) => step.status === 'running') ?? steps[steps.length - 1]
+ const progress = plan.length
+ ? Math.round((planDone / plan.length) * 100)
+ : steps.length
+ ? Math.round((completed / steps.length) * 100)
+ : 18
+ const countLabel = plan.length ? `${planDone}/${plan.length}` : steps.length ? `${completed}/${steps.length}` : null
+ const currentTitle = current?.title?.trim()
+ const currentOverlapsPlan = Boolean(
+ currentTitle && plan.some((task) => task.content.includes(currentTitle) || currentTitle.includes(task.content)),
+ )
+ const liveText = streamText?.trim()
+ const streamRef = useRef(null)
+
+ useEffect(() => {
+ const el = streamRef.current
+ if (!el || !liveText || stopped) return
+ el.scrollTop = el.scrollHeight
+ }, [liveText, stopped])
+
+ const title = stopped
+ ? t('assistant.execution.stopped')
+ : plan.length
+ ? 'در حال اجرای برنامه کار'
+ : t('assistant.execution.thinking')
+
+ const Tag = stopped ? 'div' : 'button'
+
+ return (
+
+ {stopped ? null : (
+
+ )}
+
+
+ {stopped ? (
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
+
+
+ {title}
+
+ {stopped && canRetry && onRetry ? (
+
+ ) : countLabel ? (
+
+
+ {countLabel}
+
+ ) : null}
+
+ {plan.length && !stopped ? (
+
+ ) : currentTitle && !liveText && !stopped ? (
+
{currentTitle}
+ ) : !liveText && !stopped ? (
+
{t('assistant.execution.processing')}
+ ) : null}
+ {plan.length && currentTitle && !currentOverlapsPlan && !liveText && !stopped ? (
+
{currentTitle}
+ ) : null}
+ {liveText ? (
+
+
+ {liveText}
+ {stopped ? null : (
+
+ )}
+
+
+ ) : stopped ? (
+
{t('assistant.execution.stoppedHint')}
+ ) : null}
+ {stopped ? null : (
+
+ )}
+
+
+
+ )
+}
+
+function ExecutionStatusChip({
+ steps,
+ isWorking,
+ onOpen,
+}: {
+ steps: ExecutionStep[]
+ isWorking: boolean
+ onOpen?: () => void
+}) {
+ if (!steps.length) return null
+
+ const completed = steps.filter((s) => s.status === 'done' || s.status === 'completed').length
+ const label = isWorking
+ ? `${completed}/${steps.length}`
+ : `${t('assistant.execution.viewExecution')} · ${steps.length}`
+
+ return (
+
+ )
+}
+
+function ResourceChip({
+ icon: Icon,
+ label,
+ hint,
+ tone,
+}: {
+ icon: typeof BookOpen
+ label: string
+ hint: string
+ tone: string
+}) {
+ return (
+
+
+
+
+
+ {label}
+ {hint}
+
+
+ )
+}
+
+function TurnAttachments({
+ files,
+ knowledge,
+ database,
+ onPreviewFile,
+}: {
+ files?: readonly SessionFileSnapshot[]
+ knowledge?: ChatMessage['attachedKnowledge']
+ database?: ChatMessage['attachedDb']
+ onPreviewFile?: (file: SessionFileSnapshot) => void
+}) {
+ const hasFiles = Boolean(files?.length)
+ const hasKnowledge = Boolean(knowledge?.name)
+ const hasDb = Boolean(database?.db_name)
+ if (!hasFiles && !hasKnowledge && !hasDb) return null
+ return (
+
+ {hasKnowledge ? (
+
+ ) : null}
+ {hasDb ? (
+
+ ) : null}
+ {hasFiles ?
: null}
+
+ )
+}
+
+export function SessionTurn({
+ userMessage,
+ assistantMessage,
+ attachedFiles,
+ attachedKnowledge,
+ attachedDb,
+ steps = [],
+ taskPlan,
+ isWorking = false,
+ thinkingContent,
+ stopped = false,
+ canRetry = false,
+ htmlReport,
+ reportLoading,
+ downloadFiles,
+ hideAttachments = false,
+ onOpenExecution,
+ onRetry,
+ onPreviewFile,
+}: {
+ userMessage: string
+ assistantMessage?: string
+ attachedFiles?: readonly SessionFileSnapshot[]
+ attachedKnowledge?: ChatMessage['attachedKnowledge']
+ attachedDb?: ChatMessage['attachedDb']
+ steps?: ExecutionStep[]
+ taskPlan?: TaskItem[]
+ isWorking?: boolean
+ thinkingContent?: string
+ stopped?: boolean
+ canRetry?: boolean
+ htmlReport?: string | null
+ reportLoading?: boolean
+ downloadFiles?: readonly DownloadableFile[]
+ hideAttachments?: boolean
+ onOpenExecution?: () => void
+ onRetry?: () => void
+ onPreviewFile?: (file: SessionFileSnapshot) => void
+}) {
+ const question = userMessage.trim()
+ const hasAnswer = Boolean(assistantMessage?.trim())
+ const displayAnswer = hasAnswer ? cleanFinalContent(assistantMessage!) : ''
+ const showCleanAnswer = Boolean(displayAnswer.trim())
+ const showTruncatedHint = showCleanAnswer && looksTruncated(displayAnswer)
+ const liveThinking = thinkingContent?.trim()
+ const showThinking = Boolean(
+ stopped || (isWorking && !hasAnswer && !htmlReport) || (liveThinking && !hasAnswer && !htmlReport),
+ )
+ const stillWorkingAfterReport = isWorking && Boolean(htmlReport) && !hasAnswer
+ const hasAttachments =
+ !hideAttachments && Boolean(attachedFiles?.length || attachedKnowledge?.name || attachedDb?.db_name)
+ const hasUser = Boolean(question || hasAttachments)
+ const hasDownloads = Boolean(downloadFiles?.length)
+ const showAssistant =
+ hasAnswer || htmlReport || reportLoading || showThinking || stillWorkingAfterReport || hasDownloads
+
+ return (
+
+ {hasUser ? (
+
+
+ {question ? (
+
+ ) : null}
+ {hasAttachments ? (
+
+
+
+ ) : null}
+
+
+ ) : null}
+
+ {showAssistant ? (
+
+
+ K
+
+
+ {showThinking ? (
+
+ ) : null}
+
+ {hasAnswer || htmlReport || reportLoading || hasDownloads ? (
+
+ {showCleanAnswer ? (
+
+
+
+ ) : null}
+ {showTruncatedHint ? (
+
{t('assistant.chat.truncatedAnswer')}
+ ) : null}
+ {hasDownloads ? (
+
+ {downloadFiles!.map((file) => (
+
+ ))}
+
+ ) : null}
+ {htmlReport || reportLoading ? (
+
+ {reportLoading && !htmlReport ? (
+
در حال ساخت گزارش…
+ ) : htmlReport ? (
+
+ ) : null}
+
+ ) : null}
+ {stillWorkingAfterReport ? (
+
در حال تکمیل تحلیل…
+ ) : null}
+
+ {showCleanAnswer ? (
+
+ ) : null}
+ {steps.length ? (
+
+ ) : null}
+
+
+ ) : null}
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/workspace/conversationLayout.ts b/src/features/assistant/workspace/conversationLayout.ts
new file mode 100644
index 0000000..085f331
--- /dev/null
+++ b/src/features/assistant/workspace/conversationLayout.ts
@@ -0,0 +1,3 @@
+/** Shared width so the thread and composer stay aligned. */
+export const conversationWidthClass =
+ 'mx-auto w-full min-w-0 max-w-5xl px-4 sm:px-6 lg:px-8'
diff --git a/src/features/auth/AuthContext.test.tsx b/src/features/auth/AuthContext.test.tsx
new file mode 100644
index 0000000..73dabc0
--- /dev/null
+++ b/src/features/auth/AuthContext.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import { AuthProvider, useAuth } from './AuthContext'
+
+const { fetchSession } = vi.hoisted(() => ({
+ fetchSession: vi.fn(),
+}))
+
+vi.mock('./api/auth.api', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ fetchSession: (...args: unknown[]) => fetchSession(...args),
+ }
+})
+
+function Probe() {
+ const auth = useAuth()
+ return (
+
+
{auth.status}
+
{auth.user?.user_id ?? ''}
+
+
+ )
+}
+
+describe('AuthProvider', () => {
+ it('does not log out when session refresh fails transiently', async () => {
+ fetchSession
+ .mockResolvedValueOnce({
+ status: 'authenticated',
+ user: { user_id: 'u1', email: 'a@b.com' },
+ activeWorkspace: null,
+ })
+ .mockResolvedValue({ status: 'unavailable' })
+
+ render(
+
+
+ ,
+ )
+
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'))
+ expect(screen.getByTestId('user')).toHaveTextContent('u1')
+
+ await userEvent.click(screen.getByRole('button', { name: 'refresh' }))
+ await waitFor(() => expect(fetchSession.mock.calls.length).toBeGreaterThan(1))
+ expect(screen.getByTestId('status')).toHaveTextContent('authenticated')
+ expect(screen.getByTestId('user')).toHaveTextContent('u1')
+ })
+})
diff --git a/src/features/auth/AuthContext.tsx b/src/features/auth/AuthContext.tsx
new file mode 100644
index 0000000..94097c1
--- /dev/null
+++ b/src/features/auth/AuthContext.tsx
@@ -0,0 +1,181 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react'
+import {
+ fetchSession,
+ loginRequest,
+ logoutRequest,
+ readPersistedUser,
+ sendOtpRequest,
+ verifyOtpRequest,
+ type ActiveWorkspace,
+ type AuthUser,
+ type OtpSendResponse,
+} from './api/auth.api'
+
+type AuthStatus = 'loading' | 'authenticated' | 'anonymous'
+
+type AuthContextValue = {
+ status: AuthStatus
+ user: AuthUser | null
+ activeWorkspace: ActiveWorkspace | null
+ login: (email: string, password: string) => Promise
+ sendOtp: (mobile: string) => Promise
+ verifyOtp: (mobile: string, code: string) => Promise
+ logout: () => Promise
+ refresh: () => Promise
+}
+
+const AuthContext = createContext(null)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [status, setStatus] = useState('loading')
+ const [user, setUser] = useState(null)
+ const [activeWorkspace, setActiveWorkspace] = useState(null)
+
+ const statusRef = useRef('loading')
+
+ const applyStatus = useCallback((next: AuthStatus) => {
+ statusRef.current = next
+ setStatus(next)
+ }, [])
+
+ const refresh = useCallback(async () => {
+ try {
+ const session = await fetchSession()
+ if (session.status === 'authenticated') {
+ setUser(session.user)
+ setActiveWorkspace(session.activeWorkspace)
+ applyStatus('authenticated')
+ return
+ }
+ if (session.status === 'anonymous') {
+ setUser(null)
+ setActiveWorkspace(null)
+ applyStatus('anonymous')
+ return
+ }
+ if (statusRef.current === 'loading') {
+ const persisted = readPersistedUser()
+ if (persisted) {
+ setUser(persisted)
+ applyStatus('authenticated')
+ }
+ }
+ } catch {
+ if (statusRef.current === 'loading') {
+ const persisted = readPersistedUser()
+ if (persisted) {
+ setUser(persisted)
+ applyStatus('authenticated')
+ }
+ }
+ }
+ }, [applyStatus])
+
+ useEffect(() => {
+ let cancelled = false
+ void (async () => {
+ let delay = 800
+ while (!cancelled) {
+ await refresh()
+ if (cancelled || statusRef.current !== 'loading') return
+ await new Promise((resolve) => window.setTimeout(resolve, delay))
+ delay = Math.min(delay * 2, 8000)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [refresh])
+
+ useEffect(() => {
+ const retry = () => {
+ void refresh()
+ }
+ const onVisible = () => {
+ if (document.visibilityState === 'visible') retry()
+ }
+ window.addEventListener('online', retry)
+ document.addEventListener('visibilitychange', onVisible)
+ return () => {
+ window.removeEventListener('online', retry)
+ document.removeEventListener('visibilitychange', onVisible)
+ }
+ }, [refresh])
+
+ // Cookie auth is shared across tabs; sync identity when another tab logs in/out.
+ useEffect(() => {
+ let channel: BroadcastChannel | null = null
+ try {
+ channel = new BroadcastChannel('kamidso.auth')
+ channel.onmessage = () => {
+ void refresh()
+ }
+ } catch {
+ channel = null
+ }
+ return () => {
+ channel?.close()
+ }
+ }, [refresh])
+
+ const notifyAuthChanged = useCallback(() => {
+ try {
+ const channel = new BroadcastChannel('kamidso.auth')
+ channel.postMessage({ type: 'auth-changed' })
+ channel.close()
+ } catch {
+ /* BroadcastChannel unavailable */
+ }
+ }, [])
+
+ const login = useCallback(async (email: string, password: string) => {
+ const result = await loginRequest(email, password)
+ setUser(result.user)
+ await refresh()
+ notifyAuthChanged()
+ }, [notifyAuthChanged, refresh])
+
+ const sendOtp = useCallback(async (mobile: string) => {
+ return sendOtpRequest(mobile)
+ }, [])
+
+ const verifyOtp = useCallback(async (mobile: string, code: string) => {
+ const result = await verifyOtpRequest(mobile, code)
+ setUser(result.user)
+ await refresh()
+ notifyAuthChanged()
+ }, [notifyAuthChanged, refresh])
+
+ const logout = useCallback(async () => {
+ try {
+ await logoutRequest()
+ } finally {
+ setUser(null)
+ setActiveWorkspace(null)
+ applyStatus('anonymous')
+ notifyAuthChanged()
+ }
+ }, [applyStatus, notifyAuthChanged])
+
+ const value = useMemo(
+ () => ({ status, user, activeWorkspace, login, sendOtp, verifyOtp, logout, refresh }),
+ [status, user, activeWorkspace, login, sendOtp, verifyOtp, logout, refresh],
+ )
+
+ return {children}
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext)
+ if (!ctx) throw new Error('useAuth must be used within AuthProvider')
+ return ctx
+}
diff --git a/src/features/auth/RequireAuth.test.tsx b/src/features/auth/RequireAuth.test.tsx
new file mode 100644
index 0000000..cccbe5d
--- /dev/null
+++ b/src/features/auth/RequireAuth.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+import { RequireAuth } from './RequireAuth'
+
+vi.mock('./AuthContext', () => ({
+ useAuth: () => ({ status: 'anonymous' }),
+}))
+
+function LoginProbe() {
+ const location = useLocation()
+ const from = (location.state as { from?: string } | null)?.from ?? ''
+ return {from}
+}
+
+describe('RequireAuth', () => {
+ it('preserves the conversation query on the login redirect', () => {
+ render(
+
+
+
+ panel
+
+ }
+ />
+ } />
+
+ ,
+ )
+ expect(screen.queryByText('panel')).not.toBeInTheDocument()
+ expect(screen.getByTestId('from')).toHaveTextContent('/assistant?id=conv_123')
+ })
+})
diff --git a/src/features/auth/RequireAuth.tsx b/src/features/auth/RequireAuth.tsx
new file mode 100644
index 0000000..de970c4
--- /dev/null
+++ b/src/features/auth/RequireAuth.tsx
@@ -0,0 +1,27 @@
+import { Navigate, useLocation } from 'react-router-dom'
+import { useAuth } from './AuthContext'
+
+export function RequireAuth({ children }: { children: React.ReactNode }) {
+ const { status } = useAuth()
+ const location = useLocation()
+
+ if (status === 'loading') {
+ return (
+
+ در حال بررسی نشست…
+
+ )
+ }
+
+ if (status !== 'authenticated') {
+ return (
+
+ )
+ }
+
+ return children
+}
diff --git a/src/features/auth/api/auth.api.test.ts b/src/features/auth/api/auth.api.test.ts
new file mode 100644
index 0000000..d667261
--- /dev/null
+++ b/src/features/auth/api/auth.api.test.ts
@@ -0,0 +1,76 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { persistUser, fetchSession, readPersistedUser } from './auth.api'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ localStorage.clear()
+})
+
+function jsonResponse(status: number, body: unknown) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: { get: () => 'application/json' },
+ json: async () => body,
+ }
+}
+
+describe('fetchSession', () => {
+ it('keeps the persisted user on a backend blip', async () => {
+ persistUser({ user_id: 'u1', email: 'a@b.com', name: 'Ali' })
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => jsonResponse(502, { message: 'bad gateway' })),
+ )
+ await expect(fetchSession()).resolves.toEqual({ status: 'unavailable' })
+ expect(readPersistedUser()?.user_id).toBe('u1')
+ })
+
+ it('keeps the persisted user on a network drop', async () => {
+ persistUser({ user_id: 'u1', email: 'a@b.com' })
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ throw new TypeError('Failed to fetch')
+ }),
+ )
+ await expect(fetchSession()).resolves.toEqual({ status: 'unavailable' })
+ expect(readPersistedUser()?.user_id).toBe('u1')
+ })
+
+ it('clears the session only on 401', async () => {
+ persistUser({ user_id: 'u1', email: 'a@b.com' })
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => jsonResponse(401, { code: 'AUTH_REQUIRED' })),
+ )
+ await expect(fetchSession()).resolves.toEqual({ status: 'anonymous' })
+ expect(readPersistedUser()).toBeNull()
+ })
+
+ it('returns the authenticated user', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () =>
+ jsonResponse(200, {
+ user: { id: 'u1', email: 'a@b.com', name: 'Ali' },
+ active_workspace: { id: 'ws1', name: 'Main' },
+ }),
+ ),
+ )
+ const session = await fetchSession()
+ expect(session).toEqual({
+ status: 'authenticated',
+ user: {
+ user_id: 'u1',
+ email: 'a@b.com',
+ name: 'Ali',
+ role: undefined,
+ workspace_id: 'ws1',
+ mobile: undefined,
+ permissions: undefined,
+ },
+ activeWorkspace: { id: 'ws1', name: 'Main' },
+ })
+ })
+})
diff --git a/src/features/auth/api/auth.api.ts b/src/features/auth/api/auth.api.ts
new file mode 100644
index 0000000..93b53cd
--- /dev/null
+++ b/src/features/auth/api/auth.api.ts
@@ -0,0 +1,159 @@
+import { ApiError, apiFetch } from '../../../services/api'
+
+export type AuthUser = {
+ user_id: string
+ email: string
+ name?: string
+ role?: string
+ workspace_id?: string
+ mobile?: string
+ permissions?: Record
+}
+
+export type ActiveWorkspace = {
+ id: string
+ organization_id?: string
+ name: string
+}
+
+export type MeResponse = {
+ user: AuthUser
+ active_workspace?: ActiveWorkspace
+ organization?: { id: string; name: string }
+ features?: Record
+ developer_mode?: boolean
+ product?: string
+}
+
+export type OtpSendResponse = {
+ ok: boolean
+ message?: string
+ dev_code?: string
+}
+
+const USER_INFO_KEY = 'user_info'
+const KAMIDSO_USER_KEY = 'kamidso_user'
+
+export function persistUser(user: AuthUser) {
+ localStorage.setItem(
+ USER_INFO_KEY,
+ JSON.stringify({
+ user_id: user.user_id,
+ user_no: user.user_id,
+ nick_name: user.name || user.email || user.mobile,
+ email: user.email,
+ mobile: user.mobile,
+ role: user.role,
+ workspace_id: user.workspace_id,
+ }),
+ )
+ localStorage.setItem(
+ KAMIDSO_USER_KEY,
+ JSON.stringify({
+ id: user.user_id,
+ email: user.email,
+ name: user.name,
+ mobile: user.mobile,
+ role: user.role,
+ workspace_id: user.workspace_id,
+ }),
+ )
+}
+
+export function readPersistedUser(): AuthUser | null {
+ try {
+ const raw = localStorage.getItem(KAMIDSO_USER_KEY) || localStorage.getItem(USER_INFO_KEY)
+ if (!raw) return null
+ return mapUser(JSON.parse(raw) as Record)
+ } catch {
+ return null
+ }
+}
+
+export function clearPersistedUser() {
+ localStorage.removeItem(USER_INFO_KEY)
+ localStorage.removeItem(KAMIDSO_USER_KEY)
+}
+
+function mapUser(raw: Record | undefined): AuthUser | null {
+ if (!raw) return null
+ const user_id = String(raw.id ?? raw.user_id ?? '')
+ if (!user_id) return null
+ const permissions =
+ raw.permissions && typeof raw.permissions === 'object'
+ ? (raw.permissions as Record)
+ : undefined
+ return {
+ user_id,
+ email: String(raw.email ?? ''),
+ name: raw.name ? String(raw.name) : undefined,
+ role: raw.role ? String(raw.role) : undefined,
+ workspace_id: raw.workspace_id ? String(raw.workspace_id) : undefined,
+ mobile: raw.mobile ? String(raw.mobile) : undefined,
+ permissions,
+ }
+}
+
+export type SessionResult =
+ | { status: 'authenticated'; user: AuthUser; activeWorkspace: ActiveWorkspace | null }
+ | { status: 'anonymous' }
+ | { status: 'unavailable' }
+
+export async function fetchSession(): Promise {
+ try {
+ const data = await apiFetch('/auth/session')
+ const user = mapUser(data.user as unknown as Record)
+ if (!user?.user_id) {
+ clearPersistedUser()
+ return { status: 'anonymous' }
+ }
+ if (data.active_workspace?.id) user.workspace_id = data.active_workspace.id
+ persistUser(user)
+ return { status: 'authenticated', user, activeWorkspace: data.active_workspace ?? null }
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 401) {
+ clearPersistedUser()
+ return { status: 'anonymous' }
+ }
+ return { status: 'unavailable' }
+ }
+}
+
+export async function loginRequest(email: string, password: string): Promise<{ user: AuthUser }> {
+ const data = await apiFetch<{ user: Record }>('/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ email, password }),
+ })
+ const user = mapUser(data.user as unknown as Record)
+ if (!user) throw new Error('ورود انجام نشد.')
+ persistUser(user)
+ return { user }
+}
+
+export async function sendOtpRequest(mobile: string): Promise {
+ return apiFetch('/auth/otp/send', {
+ method: 'POST',
+ body: JSON.stringify({ mobile }),
+ })
+}
+
+export async function verifyOtpRequest(mobile: string, code: string): Promise<{ user: AuthUser }> {
+ const data = await apiFetch<{ user: Record }>('/auth/otp/verify', {
+ method: 'POST',
+ body: JSON.stringify({ mobile, code }),
+ })
+ const user = mapUser(data.user as unknown as Record)
+ if (!user) throw new Error('ورود انجام نشد.')
+ persistUser(user)
+ return { user }
+}
+
+export async function logoutRequest(): Promise {
+ await apiFetch('/auth/logout', { method: 'POST' })
+ clearPersistedUser()
+ try {
+ sessionStorage.removeItem('kamidso.activeRunByConv')
+ } catch {
+ /* ignore private mode */
+ }
+}
diff --git a/src/features/auth/pages/LoginPage.tsx b/src/features/auth/pages/LoginPage.tsx
new file mode 100644
index 0000000..5757765
--- /dev/null
+++ b/src/features/auth/pages/LoginPage.tsx
@@ -0,0 +1,325 @@
+import { useEffect, useState, type FormEvent } from 'react'
+import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
+import { ArrowLeft, Lock, Mail, Smartphone } from 'lucide-react'
+import { ApiError } from '../../../services/api'
+import { useAuth } from '../AuthContext'
+
+type LoginMode = 'sms' | 'email'
+type SmsStep = 'mobile' | 'code'
+
+export function LoginPage() {
+ const { status, login, sendOtp, verifyOtp } = useAuth()
+ const navigate = useNavigate()
+ const location = useLocation()
+ const from =
+ (location.state as { from?: string } | null)?.from &&
+ !(location.state as { from?: string }).from?.startsWith('/login')
+ ? (location.state as { from: string }).from
+ : '/assistant'
+
+ const [mode, setMode] = useState('sms')
+ const [smsStep, setSmsStep] = useState('mobile')
+ const [mobile, setMobile] = useState('')
+ const [code, setCode] = useState('')
+ const [devCode, setDevCode] = useState(null)
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+
+ useEffect(() => {
+ const previous = document.title
+ document.title = 'ورود — Kamidso'
+ return () => {
+ document.title = previous
+ }
+ }, [])
+
+ if (status === 'authenticated') {
+ return
+ }
+
+ function goAfterLogin() {
+ navigate(from, { replace: true })
+ }
+
+ async function requestOtp() {
+ setError(null)
+ setSubmitting(true)
+ try {
+ const result = await sendOtp(mobile.trim())
+ setDevCode(result.dev_code ?? null)
+ setSmsStep('code')
+ setCode('')
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError('ارسال کد انجام نشد. دوباره تلاش کنید.')
+ }
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ async function onSendOtp(event: FormEvent) {
+ event.preventDefault()
+ await requestOtp()
+ }
+
+ async function onVerifyOtp(event: FormEvent) {
+ event.preventDefault()
+ setError(null)
+ setSubmitting(true)
+ try {
+ await verifyOtp(mobile.trim(), code.trim())
+ goAfterLogin()
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError('ورود انجام نشد. دوباره تلاش کنید.')
+ }
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ async function onEmailSubmit(event: FormEvent) {
+ event.preventDefault()
+ setError(null)
+ setSubmitting(true)
+ try {
+ await login(email.trim(), password)
+ goAfterLogin()
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError('ورود انجام نشد. دوباره تلاش کنید.')
+ }
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ K
+
+
+ Kamidso
+ راهبر هوشمند داده
+
+
+
+
+
+
ورود به پنل
+
+ {mode === 'sms'
+ ? 'با شماره موبایل وارد شوید. هر کاربر محیط و دادههای خودش را دارد.'
+ : 'ورود ادمین با ایمیل و رمز عبور.'}
+
+
+ {mode === 'sms' && smsStep === 'mobile' ? (
+
+ ) : null}
+
+ {mode === 'sms' && smsStep === 'code' ? (
+
+ ) : null}
+
+ {mode === 'email' ? (
+
+ ) : null}
+
+
+ {mode === 'sms' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ صفحه اصلی
+
+
+
+
+ )
+}
diff --git a/src/features/auth/useLogoutConfirm.test.tsx b/src/features/auth/useLogoutConfirm.test.tsx
new file mode 100644
index 0000000..1ef984a
--- /dev/null
+++ b/src/features/auth/useLogoutConfirm.test.tsx
@@ -0,0 +1,58 @@
+import { render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+import { useLogoutConfirm } from './useLogoutConfirm'
+
+const logout = vi.fn(async () => {})
+
+vi.mock('./AuthContext', () => ({
+ useAuth: () => ({ logout }),
+}))
+
+function Probe() {
+ const { ask, dialog } = useLogoutConfirm()
+ return (
+ <>
+
+ {dialog}
+ >
+ )
+}
+
+describe('useLogoutConfirm', () => {
+ it('asks before logging out and can be cancelled', async () => {
+ const user = userEvent.setup()
+ logout.mockClear()
+ render(
+
+
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'خروج' }))
+ expect(screen.getByText('مطمئن هستید که میخواهید از حساب کاربری خارج شوید؟')).toBeInTheDocument()
+ expect(logout).not.toHaveBeenCalled()
+
+ await user.click(screen.getByRole('button', { name: 'انصراف' }))
+ expect(logout).not.toHaveBeenCalled()
+ expect(screen.queryByText('مطمئن هستید که میخواهید از حساب کاربری خارج شوید؟')).not.toBeInTheDocument()
+ })
+
+ it('logs out only after confirmation', async () => {
+ const user = userEvent.setup()
+ logout.mockClear()
+ render(
+
+
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'خروج' }))
+ const dialog = screen.getByRole('dialog', { name: 'خروج از حساب' })
+ await user.click(within(dialog).getByRole('button', { name: 'خروج' }))
+ expect(logout).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/features/auth/useLogoutConfirm.tsx b/src/features/auth/useLogoutConfirm.tsx
new file mode 100644
index 0000000..66d43a4
--- /dev/null
+++ b/src/features/auth/useLogoutConfirm.tsx
@@ -0,0 +1,44 @@
+import { useCallback, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
+import { useAuth } from './AuthContext'
+
+export function useLogoutConfirm() {
+ const { logout } = useAuth()
+ const navigate = useNavigate()
+ const [open, setOpen] = useState(false)
+ const [busy, setBusy] = useState(false)
+
+ const ask = useCallback(() => setOpen(true), [])
+ const cancel = useCallback(() => {
+ if (busy) return
+ setOpen(false)
+ }, [busy])
+
+ const confirm = useCallback(async () => {
+ setBusy(true)
+ try {
+ await logout()
+ navigate('/login', { replace: true })
+ } finally {
+ setBusy(false)
+ setOpen(false)
+ }
+ }, [logout, navigate])
+
+ const dialog = (
+ void confirm()}
+ onCancel={cancel}
+ />
+ )
+
+ return { ask, dialog }
+}
diff --git a/src/features/data-map/DataMapPage.tsx b/src/features/data-map/DataMapPage.tsx
new file mode 100644
index 0000000..eeb489c
--- /dev/null
+++ b/src/features/data-map/DataMapPage.tsx
@@ -0,0 +1,559 @@
+import { useEffect, useRef, useState } from 'react'
+import { Database, FileSpreadsheet, Loader2, Network, Sparkles, Square, Wand2 } from 'lucide-react'
+import { Button } from '../../components/Button'
+import { PageHeader } from '../../components/ui/PageHeader'
+import { PageShell } from '../../components/ui/PageShell'
+import { t } from '../../i18n'
+import { listDatasourceRecords, type DatasourceRecord } from '../databases/api'
+import { listWorkspaceFiles, type KamidsoFile } from '../assistant/session-files'
+import { cancelDiscovery, createEdge, deleteEdge, listEdges, listMaps, listSemanticReview, liveHighlightFrom, setEdgeStatus, streamAgent, wrapDiscovery, type LiveHighlight, type TraceEvent } from './api'
+import { DataMapCanvas } from './components/DataMapCanvas'
+import { FieldSemanticPanel } from './components/FieldSemanticPanel'
+import { SemanticReviewList } from './components/SemanticReviewList'
+import { TracePanel } from './components/TracePanel'
+import type { DataEdge, FieldTarget, SemanticReviewItem, SourceMap } from './types'
+import { discoveryProgress, locateField } from './types'
+
+type ResourceRow = { id: string; name: string; kind: string }
+type SuggestScope = 'all' | 'internal' | 'external'
+
+function fieldIdFromHandle(handle: string | null | undefined): string {
+ if (!handle) return ''
+ return handle.replace(/^(in|out):/, '')
+}
+
+function isSourceMap(value: unknown): value is SourceMap {
+ return Boolean(value && typeof value === 'object' && 'resource_id' in value && 'datasets' in value)
+}
+
+function ModelMark({ ratio }: { ratio: number }) {
+ const turn = Math.max(0.08, Math.min(ratio || 0.08, 1))
+ return (
+
+
+
+
+ )
+}
+
+function resourceLabel(map: SourceMap | undefined, running: boolean, known: boolean): string {
+ const progress = discoveryProgress(map)
+ if (map?.status === 'discovering' || running) {
+ if (progress && progress.total) {
+ return t('dataMap.discoveringProgress').replace('{done}', String(progress.done)).replace('{total}', String(progress.total))
+ }
+ return t('dataMap.discovering')
+ }
+ return known ? t('dataMap.discovered') : t('dataMap.notDiscovered')
+}
+
+export function DataMapPage() {
+ const [maps, setMaps] = useState([])
+ const [edges, setEdges] = useState([])
+ const [review, setReview] = useState([])
+ const [resources, setResources] = useState([])
+ const [selected, setSelected] = useState(null)
+ const [error, setError] = useState(null)
+ const [notice, setNotice] = useState(null)
+ const [busy, setBusy] = useState(null)
+ const [trace, setTrace] = useState([])
+ const [traceCollapsed, setTraceCollapsed] = useState(false)
+ const [highlight, setHighlight] = useState(null)
+ const [hold, setHold] = useState<{ id: string; mode: 'cancel' | 'wrap' } | null>(null)
+ const [suggestScope, setSuggestScope] = useState('all')
+ const attached = useRef(new Set())
+
+ function upsertMap(next: SourceMap) {
+ setMaps((current) => {
+ const others = current.filter((item) => item.resource_id !== next.resource_id)
+ if (next.status !== 'ready' && next.status !== 'discovering') return others
+ if (next.status === 'discovering' && !next.datasets.length) {
+ const inventory = Array.isArray(next.coverage_detail?.inventory) ? next.coverage_detail.inventory : []
+ if (!inventory.length) return others
+ }
+ return [...others, next]
+ })
+ }
+
+ async function reload() {
+ const [nextMaps, nextEdges, nextReview, files, databases] = await Promise.all([
+ listMaps(),
+ listEdges(),
+ listSemanticReview(),
+ listWorkspaceFiles(),
+ listDatasourceRecords(),
+ ])
+ const visible = nextMaps.filter((item) => {
+ if (item.status === 'ready') return true
+ if (item.status !== 'discovering') return false
+ const inventory = Array.isArray(item.coverage_detail?.inventory) ? item.coverage_detail.inventory : []
+ return item.datasets.length > 0 || inventory.length > 0
+ })
+ setMaps(visible)
+ setEdges(nextEdges)
+ setReview(nextReview)
+ setResources([
+ ...files.map((file: KamidsoFile) => ({
+ id: file.id,
+ name: file.title?.trim() || file.name,
+ kind: file.kind || 'file',
+ })),
+ ...databases.map((item: DatasourceRecord) => ({
+ id: item.id,
+ name: item.name,
+ kind: item.type,
+ })),
+ ])
+ setSelected((current) => {
+ if (!current) return null
+ return locateField(visible, current.field.id)
+ })
+ for (const item of visible) {
+ if (item.status === 'discovering') {
+ void attachDiscovery(item.resource_id, { fresh: false })
+ }
+ }
+ }
+
+ useEffect(() => {
+ reload().catch((err: Error) => setError(err.message))
+ }, [])
+
+ async function attachDiscovery(resourceId: string, opts?: { fresh?: boolean }) {
+ const path = `/data-graph/resources/${encodeURIComponent(resourceId)}/discover/stream`
+ if (attached.current.has(resourceId)) {
+ setBusy(resourceId)
+ setTraceCollapsed(false)
+ return
+ }
+ attached.current.add(resourceId)
+ setBusy(resourceId)
+ setError(null)
+ setNotice(null)
+ setTraceCollapsed(false)
+ setSelected(null)
+ if (opts?.fresh !== false) {
+ setTrace([{ type: 'status', message: 'شروع شد' }])
+ setHighlight(null)
+ }
+ try {
+ const result = await streamAgent(path, (event) => {
+ const nextHighlight = liveHighlightFrom(event)
+ if (nextHighlight) {
+ setHighlight(nextHighlight)
+ return
+ }
+ if (event.type === 'map' && isSourceMap(event.result)) {
+ upsertMap(event.result)
+ return
+ }
+ if (event.type === 'done') {
+ if (isSourceMap(event.result)) upsertMap(event.result)
+ return
+ }
+ setTrace((current) => {
+ const last = current[current.length - 1]
+ if (event.type === 'status' && last?.type === 'status' && last.message === event.message) {
+ return current
+ }
+ return [...current, event]
+ })
+ })
+ if (isSourceMap(result)) upsertMap(result)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'اجرای Agent ناموفق بود')
+ } finally {
+ attached.current.delete(resourceId)
+ setHighlight(null)
+ setHold((current) => (current?.id === resourceId ? null : current))
+ setBusy((current) => (current === resourceId ? null : current))
+ }
+ await reload()
+ }
+
+ async function stopModeling(resourceId: string, mode: 'cancel' | 'wrap') {
+ setHold({ id: resourceId, mode })
+ setError(null)
+ setNotice(mode === 'wrap' ? 'جمعبندی تا همینجا شروع شد.' : 'توقف کشف شروع شد.')
+ try {
+ const result = await (mode === 'wrap' ? wrapDiscovery(resourceId) : cancelDiscovery(resourceId))
+ if (isSourceMap(result)) {
+ upsertMap(result)
+ setHold(null)
+ await reload()
+ }
+ } catch (err) {
+ setHold(null)
+ setError(err instanceof Error ? err.message : 'توقف ناموفق بود')
+ }
+ }
+
+ async function runSuggest() {
+ setBusy('suggest')
+ setError(null)
+ setNotice(null)
+ setTraceCollapsed(false)
+ setSelected(null)
+ setTrace([{ type: 'status', message: 'شروع شد' }])
+ setHighlight(null)
+ try {
+ let suggestions = 0
+ await streamAgent(
+ '/data-graph/suggestions/stream',
+ (event) => {
+ const nextHighlight = liveHighlightFrom(event)
+ if (nextHighlight) {
+ setHighlight(nextHighlight)
+ return
+ }
+ if (event.type === 'done') {
+ suggestions = Array.isArray(event.result) ? event.result.length : suggestions
+ return
+ }
+ setTrace((current) => {
+ const last = current[current.length - 1]
+ if (event.type === 'status' && last?.type === 'status' && last.message === event.message) {
+ return current
+ }
+ return [...current, event]
+ })
+ },
+ { scope: suggestScope },
+ )
+ await reload()
+ if (suggestions === 0) {
+ setNotice(t('dataMap.suggestNone'))
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'اجرای Agent ناموفق بود')
+ } finally {
+ setHighlight(null)
+ setBusy(null)
+ }
+ }
+
+ const suggestions = edges.filter((edge) => edge.status === 'suggested')
+ const confirmedLinks = edges.filter((edge) => edge.status === 'confirmed')
+ const discovering = maps.some((item) => item.status === 'discovering') || (busy !== null && busy !== 'suggest')
+ const hasTraceWork = busy !== null || trace.length > 0 || discovering
+ const showTracePanel = !selected && hasTraceWork && !traceCollapsed && busy !== 'suggest'
+ const showAside = Boolean(selected) || showTracePanel
+ const showTraceTab = !selected && hasTraceWork && traceCollapsed && busy !== 'suggest'
+ const readyCount = maps.filter((item) => item.status === 'ready').length
+ const hasInternal = maps.some((item) => item.status === 'ready' && item.datasets.length >= 2)
+ const canSuggest =
+ busy !== 'suggest' &&
+ (suggestScope === 'internal' ? hasInternal : suggestScope === 'external' ? readyCount >= 2 : hasInternal || readyCount >= 2)
+ const suggestHint = !canSuggest && busy !== 'suggest'
+ ? suggestScope === 'internal'
+ ? t('dataMap.suggestNeedInternal')
+ : t('dataMap.suggestNeedTwo')
+ : suggestScope === 'internal'
+ ? t('dataMap.suggestHintInternal')
+ : suggestScope === 'external'
+ ? t('dataMap.suggestHintExternal')
+ : t('dataMap.suggestHintAll')
+ const currentStep = busy === 'suggest' || ((hasInternal || readyCount >= 2) && !suggestions.length) ? 2 : suggestions.length ? 3 : 1
+
+ return (
+
+
+
+
+
+ >
+ }
+ action={
+
+
+ {(['all', 'internal', 'external'] as SuggestScope[]).map((scope) => (
+
+ ))}
+
+
+
{suggestHint}
+ {suggestions.length ? (
+
+ {t('dataMap.suggestPending').replace('{count}', String(suggestions.length))}
+
+ ) : null}
+
+ }
+ />
+ {error ? {error}
: null}
+ {notice ? {notice}
: null}
+ setSelected(locateField(maps, fieldId))}
+ onChanged={reload}
+ onError={setError}
+ />
+
+
+
+
{t('dataMap.sourcesTitle')}
+
{t('dataMap.sourcesHint')}
+
+
+ {resources.length ? (
+
+ {resources.map((resource) => {
+ const map = maps.find((item) => item.resource_id === resource.id)
+ const known = Boolean(map)
+ const running = busy === resource.id || map?.status === 'discovering'
+ const file = resource.kind === 'excel' || resource.kind === 'csv' || resource.kind === 'json'
+ const Icon = file ? FileSpreadsheet : Database
+ const progress = discoveryProgress(map)
+ const ratio = progress && progress.total ? progress.done / progress.total : 0.08
+ const pausing = hold?.id === resource.id ? hold.mode : null
+ const statusText = pausing === 'wrap'
+ ? t('dataMap.wrapping')
+ : pausing === 'cancel'
+ ? t('dataMap.stopping')
+ : resourceLabel(map, running, known)
+ return (
+
+ {running ? : null}
+
+ {known && !running ? (
+
+ ) : null}
+ {running ? (
+
+
+
+
+ ) : null}
+
+ )
+ })}
+
+ ) : (
+
+ {t('dataMap.sourcesEmpty')}
+
+ )}
+
+ {maps.length ? (
+
+
{t('dataMap.canvasHint')}
+
+
+
+ {t('dataMap.legendSuggested')}
+ {suggestions.length ? ` (${suggestions.length})` : ''}
+
+
+
+ {t('dataMap.legendConfirmed')}
+ {confirmedLinks.length ? ` (${confirmedLinks.length})` : ''}
+
+
+
+ ) : null}
+
+ {maps.length ? (
+
{
+ const from = fieldIdFromHandle(connection.sourceHandle)
+ const to = fieldIdFromHandle(connection.targetHandle)
+ if (!from || !to || from === to) return
+ void createEdge(from, to).then(() => reload()).catch((err: Error) => setError(err.message))
+ }}
+ onDeleteEdge={(edgeId) => {
+ void deleteEdge(edgeId).then(() => reload()).catch((err: Error) => setError(err.message))
+ }}
+ onConfirmEdge={(edgeId) => {
+ void setEdgeStatus(edgeId, 'confirmed').then(() => reload()).catch((err: Error) => setError(err.message))
+ }}
+ />
+ ) : (
+
+ {t('dataMap.canvasEmpty')}
+
+ )}
+ {selected ? (
+ setSelected(null)}
+ onChanged={reload}
+ onError={setError}
+ />
+ ) : null}
+ {showTracePanel ? (
+
+ {
+ setTrace([])
+ setTraceCollapsed(false)
+ }}
+ onCollapse={() => setTraceCollapsed(true)}
+ />
+
+ ) : null}
+
+ {showTraceTab ? (
+
+ ) : null}
+
+ )
+}
+
+function StepChip({ active, label }: { active: boolean; label: string }) {
+ return (
+
+ {label}
+
+ )
+}
+
+function kindLabel(kind: string): string {
+ const labels: Record = {
+ excel: 'Excel',
+ csv: 'CSV',
+ json: 'JSON',
+ sqlite: 'SQLite',
+ sql: 'SQL',
+ postgres: 'Postgres',
+ mysql: 'MySQL',
+ mongodb: 'MongoDB',
+ openapi: 'API',
+ }
+ return labels[kind] || kind || 'منبع'
+}
diff --git a/src/features/data-map/api.test.ts b/src/features/data-map/api.test.ts
new file mode 100644
index 0000000..55a7696
--- /dev/null
+++ b/src/features/data-map/api.test.ts
@@ -0,0 +1,82 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { liveHighlightFrom, streamAgent } from './api'
+
+function readerFrom(chunks: string[]) {
+ const encoder = new TextEncoder()
+ let index = 0
+ return {
+ read: async () => {
+ if (index >= chunks.length) return { done: true, value: undefined }
+ const value = encoder.encode(chunks[index])
+ index += 1
+ return { done: false, value }
+ },
+ }
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('liveHighlightFrom', () => {
+ it('keeps the field the model is writing', () => {
+ expect(
+ liveHighlightFrom({
+ type: 'highlight',
+ field: 'emal_price',
+ meaning: 'The exercise price',
+ label: 'exercise_strike_price',
+ count: 2,
+ recent: [{ name: 'created_at', meaning: 'created' }, { name: 'emal_price', meaning: 'The exercise price' }],
+ }),
+ ).toMatchObject({ field: 'emal_price', meaning: 'The exercise price', count: 2, kind: 'field' })
+ })
+
+ it('keeps the link the model is writing', () => {
+ expect(
+ liveHighlightFrom({
+ type: 'highlight',
+ kind: 'link',
+ from: 'orders.customer_id',
+ to: 'customers.id',
+ meaning: 'same customer',
+ label: 'foreign_key',
+ count: 1,
+ }),
+ ).toMatchObject({ kind: 'link', from: 'orders.customer_id', to: 'customers.id', meaning: 'same customer' })
+ })
+})
+
+describe('streamAgent', () => {
+ it('treats a dropped stream as background continuation', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({
+ ok: true,
+ body: { getReader: () => readerFrom(['data: {"type":"status","message":"شروع شد"}\n\n']) },
+ })),
+ )
+ const events: string[] = []
+ await expect(streamAgent('/data-graph/x', (event) => events.push(event.type))).resolves.toBeUndefined()
+ expect(events).toEqual(['status', 'status'])
+ })
+
+ it('resolves only after the done event', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({
+ ok: true,
+ body: {
+ getReader: () =>
+ readerFrom([
+ 'data: {"type":"status","message":"شروع شد"}\n\n',
+ 'data: {"type":"done","result":{"resource_id":"file"}}\n\n',
+ ]),
+ },
+ })),
+ )
+ const events: string[] = []
+ await streamAgent('/data-graph/x', (event) => events.push(event.type))
+ expect(events).toEqual(['status', 'done'])
+ })
+})
diff --git a/src/features/data-map/api.ts b/src/features/data-map/api.ts
new file mode 100644
index 0000000..7c1c7e6
--- /dev/null
+++ b/src/features/data-map/api.ts
@@ -0,0 +1,197 @@
+import { apiFetchEnvelope, kamidsoUrl } from '../../services/api'
+import type { DataEdge, FieldFacts, SemanticReviewItem, SourceDataset, SourceMap } from './types'
+
+export type LiveField = {
+ name: string
+ meaning: string
+}
+
+export type LiveHighlight = {
+ kind: 'field' | 'link'
+ thought: string
+ field: string
+ meaning: string
+ label: string
+ from: string
+ to: string
+ count: number
+ recent: LiveField[]
+}
+
+export type TraceEvent = {
+ type: 'status' | 'call' | 'observation' | 'done' | 'error' | 'map' | 'highlight'
+ message?: string
+ action?: string
+ input?: string
+ output?: string
+ result?: unknown
+ thought?: string
+ field?: string
+ meaning?: string
+ label?: string
+ kind?: 'field' | 'link'
+ from?: string
+ to?: string
+ count?: number
+ recent?: LiveField[]
+}
+
+export function liveHighlightFrom(event: TraceEvent): LiveHighlight | null {
+ if (event.type !== 'highlight') return null
+ const field = event.field?.trim() || ''
+ const thought = event.thought?.trim() || ''
+ const from = event.from?.trim() || ''
+ const to = event.to?.trim() || ''
+ const kind = event.kind === 'link' || from || to ? 'link' : 'field'
+ if (kind === 'field' && !field && !thought) return null
+ if (kind === 'link' && !from && !to && !thought) return null
+ return {
+ kind,
+ thought,
+ field,
+ meaning: event.meaning?.trim() || '',
+ label: event.label?.trim() || '',
+ from,
+ to,
+ count: event.count || 0,
+ recent: Array.isArray(event.recent) ? event.recent.filter((item) => item?.name) : [],
+ }
+}
+
+export async function streamAgent(
+ path: string,
+ onEvent: (event: TraceEvent) => void,
+ body: Record = {},
+): Promise {
+ const response = await fetch(kamidsoUrl(path), {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
+ body: JSON.stringify(body),
+ })
+ if (!response.ok || !response.body) {
+ const data = (await response.json().catch(() => null)) as { detail?: { message?: string }; message?: string } | null
+ throw new Error(data?.detail?.message || data?.message || 'درخواست ناموفق بود')
+ }
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+ let result: unknown
+ let finished = false
+ while (true) {
+ const { value, done } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+ const chunks = buffer.split('\n\n')
+ buffer = chunks.pop() || ''
+ for (const chunk of chunks) {
+ const line = chunk.split('\n').find((item) => item.startsWith('data:'))
+ if (!line) continue
+ let event: TraceEvent
+ try {
+ event = JSON.parse(line.slice(5).trim()) as TraceEvent
+ } catch {
+ continue
+ }
+ onEvent(event)
+ if (event.type === 'error') throw new Error(event.message || 'اجرای Agent ناموفق بود')
+ if (event.type === 'done') {
+ finished = true
+ result = event.result
+ }
+ }
+ }
+ if (!finished) {
+ onEvent({ type: 'status', message: 'در پسزمینه ادامه دارد' })
+ return undefined
+ }
+ return result
+}
+
+export function listMaps(): Promise {
+ return apiFetchEnvelope('/data-graph/maps')
+}
+
+export function listEdges(): Promise {
+ return apiFetchEnvelope('/data-graph/edges')
+}
+
+export function cancelDiscovery(resourceId: string): Promise {
+ return apiFetchEnvelope(`/data-graph/resources/${encodeURIComponent(resourceId)}/discover/cancel`, {
+ method: 'POST',
+ body: JSON.stringify({}),
+ })
+}
+
+export function wrapDiscovery(resourceId: string): Promise {
+ return apiFetchEnvelope(`/data-graph/resources/${encodeURIComponent(resourceId)}/discover/wrap`, {
+ method: 'POST',
+ body: JSON.stringify({}),
+ })
+}
+
+export function discoverResource(resourceId: string): Promise {
+ return apiFetchEnvelope(`/data-graph/resources/${encodeURIComponent(resourceId)}/discover`, {
+ method: 'POST',
+ body: JSON.stringify({}),
+ })
+}
+
+export function updateMap(resourceId: string, datasets: SourceDataset[]): Promise {
+ return apiFetchEnvelope(`/data-graph/maps/${encodeURIComponent(resourceId)}`, {
+ method: 'PUT',
+ body: JSON.stringify({ datasets }),
+ })
+}
+
+export function createEdge(fromField: string, toField: string): Promise {
+ return apiFetchEnvelope('/data-graph/edges', {
+ method: 'POST',
+ body: JSON.stringify({ from_field: fromField, to_field: toField, type: 'custom' }),
+ })
+}
+
+export function deleteEdge(edgeId: string): Promise<{ id: string }> {
+ return apiFetchEnvelope<{ id: string }>(`/data-graph/edges/${encodeURIComponent(edgeId)}`, {
+ method: 'DELETE',
+ })
+}
+
+export function setEdgeStatus(edgeId: string, status: 'confirmed' | 'rejected'): Promise {
+ return apiFetchEnvelope(`/data-graph/edges/${encodeURIComponent(edgeId)}`, {
+ method: 'PATCH',
+ body: JSON.stringify({ status }),
+ })
+}
+
+export function suggestEdges(): Promise {
+ return apiFetchEnvelope('/data-graph/suggestions', {
+ method: 'POST',
+ body: JSON.stringify({}),
+ })
+}
+
+export function listSemanticReview(): Promise