Initial commit of the Kamidso web UI.
This commit is contained in:
commit
13c6e7ade6
283 files changed
+35516
No files matched your search
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.git
|
||||
.github
|
||||
*.local
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.env
|
||||
.env.*
|
||||
coverage
|
||||
.vite
|
||||
.cache
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Baked into `vite build`. Origin only; the app appends /api/kamidso/v1.
|
||||
VITE_API_BASE_URL=https://api.kamidso.com
|
||||
@@ -0,0 +1 @@
|
||||
nginx.prod.conf text eol=lf
|
||||
+34
@@ -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?
|
||||
+59
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kamidso</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Kamidso — راهبر هوشمند داده: دادههای خود را تحلیل کنید، داشبورد زنده بسازید و گزارش مدیریتی بگیرید."
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"app": "kamidso-front",
|
||||
"platform": "react",
|
||||
"port": 80,
|
||||
"react": {
|
||||
"mirror": false
|
||||
}
|
||||
}
|
||||
+61
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+7114
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppRouter } from './app/router'
|
||||
|
||||
export default function App() {
|
||||
return <AppRouter />
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
KAMIDSO_API,
|
||||
kamidsoUrl,
|
||||
ApiError,
|
||||
apiFetch,
|
||||
apiFetchEnvelope,
|
||||
getUserId,
|
||||
isDebugMode,
|
||||
type KamidsoEnvelope,
|
||||
} from '../services/api'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<NavItem, 'to' | 'match' | 'end'>) {
|
||||
if (item.end) return pathname === item.to
|
||||
return item.match.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))
|
||||
}
|
||||
@@ -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<void>
|
||||
isEnabled: (key: keyof FeatureFlags) => boolean
|
||||
}
|
||||
|
||||
const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null)
|
||||
|
||||
export function FeatureFlagProvider({ children }: { children: ReactNode }) {
|
||||
const { status } = useAuth()
|
||||
const [flags, setFlags] = useState<FeatureFlags>(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<FeatureFlags>
|
||||
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 <FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>
|
||||
}
|
||||
|
||||
export function useFeatureFlags() {
|
||||
const ctx = useContext(FeatureFlagContext)
|
||||
if (!ctx) throw new Error('useFeatureFlags must be used within FeatureFlagProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -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 (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/landing" element={<Navigate to="/" replace />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/share/reports/:reportId" element={<ShareReportPage />} />
|
||||
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<FeatureFlagProvider>
|
||||
<WorkspaceProvider>
|
||||
<AppShell>
|
||||
<Routes>
|
||||
<Route path="/home" element={<Navigate to="/assistant" replace />} />
|
||||
<Route path="/assistant" element={<AssistantPage />} />
|
||||
<Route path="/data-map" element={<DataMapPage />} />
|
||||
<Route path="/databases" element={<DatabasesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/knowledge/:id" element={<KnowledgeDetailPage />} />
|
||||
<Route path="/skills" element={<SkillsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/organization" element={<OrganizationProfilePage />} />
|
||||
<Route path="/settings/skills" element={<Navigate to="/skills" replace />} />
|
||||
<Route path="/settings/skills/:skillId" element={<SkillSettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/assistant" replace />} />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
</WorkspaceProvider>
|
||||
</FeatureFlagProvider>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext, type PropsWithChildren } from 'react'
|
||||
import { useRunStream } from '../../features/assistant/stream/useRunStream'
|
||||
|
||||
type AssistantRuntime = ReturnType<typeof useRunStream>
|
||||
|
||||
const AssistantRuntimeContext = createContext<AssistantRuntime | null>(null)
|
||||
|
||||
export function AssistantRuntimeProvider({ children }: PropsWithChildren) {
|
||||
const stream = useRunStream()
|
||||
return (
|
||||
<AssistantRuntimeContext.Provider value={stream}>{children}</AssistantRuntimeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAssistantRuntime() {
|
||||
const ctx = useContext(AssistantRuntimeContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useAssistantRuntime must be used within AssistantRuntimeProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<span data-testid="count">{items.length}</span>
|
||||
<span data-testid="more">{String(hasMore)}</span>
|
||||
<ul>
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>{item.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" onClick={() => void nextPage()}>
|
||||
next
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<ConversationsProvider>
|
||||
<Probe />
|
||||
</ConversationsProvider>,
|
||||
)
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -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<boolean>
|
||||
prevPage: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
const ConversationsContext = createContext<ConversationsContextValue | null>(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<ConversationListItem[]>([])
|
||||
const [items, setItems] = useState<ConversationListItem[]>([])
|
||||
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 <ConversationsContext.Provider value={value}>{children}</ConversationsContext.Provider>
|
||||
}
|
||||
|
||||
export function useConversations() {
|
||||
const ctx = useContext(ConversationsContext)
|
||||
if (!ctx) throw new Error('useConversations must be used within ConversationsProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -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<WorkspaceContextValue | null>(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 <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider>
|
||||
}
|
||||
|
||||
export function useWorkspace() {
|
||||
const ctx = useContext(WorkspaceContext)
|
||||
if (!ctx) throw new Error('useWorkspace must be used within WorkspaceProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ButtonHTMLAttributes, PropsWithChildren } from 'react'
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||
type Size = 'sm' | 'md' | 'lg'
|
||||
|
||||
const styles: Record<Variant, string> = {
|
||||
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<Size, string> = {
|
||||
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<HTMLButtonElement> & {
|
||||
variant?: Variant
|
||||
size?: Size
|
||||
}
|
||||
>
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
className = '',
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
type = 'button',
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={`inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-xl font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 disabled:cursor-not-allowed ${styles[variant]} ${sizes[size]} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<section
|
||||
className={`rounded-2xl border border-line bg-panel p-5 shadow-[0_1px_2px_rgb(23_32_51/0.04)] transition hover:border-line-strong ${className}`}
|
||||
>
|
||||
{(title || action) && (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
{title ? <h2 className="text-base font-semibold text-ink">{title}</h2> : <span />}
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
export function ErrorMessage({ message }: { message: string }) {
|
||||
if (!message) return null
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2.5 rounded-xl border border-danger/20 bg-danger-soft px-4 py-3 text-sm leading-6 text-danger"
|
||||
>
|
||||
<AlertCircle className="mt-1 h-4 w-4 shrink-0" />
|
||||
<p className="min-w-0">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { LoaderCircle } from 'lucide-react'
|
||||
|
||||
export function Loading({ label = 'در حال بارگذاری...' }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-ink-muted">
|
||||
<LoaderCircle className="h-5 w-5 animate-spin text-accent" />
|
||||
<span className="text-sm">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Size, string> = {
|
||||
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(
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-ink/45 backdrop-blur-[2px]"
|
||||
aria-label="بستن"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className={`relative z-10 flex max-h-[calc(100dvh-2rem)] w-full ${WIDTHS[size]} flex-col overflow-hidden rounded-2xl border border-line bg-panel shadow-2xl`}
|
||||
>
|
||||
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-line px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-ink">{title}</h3>
|
||||
{description ? (
|
||||
<p className="mt-1 text-xs leading-5 text-ink-muted">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بستن"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-ink-muted transition hover:bg-surface-subtle hover:text-ink"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||
{footer ? (
|
||||
<div className="flex w-full shrink-0 items-center justify-end gap-2 border-t border-line bg-surface-subtle/70 px-5 py-3.5">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -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 <div className="app-scroll h-dvh overflow-y-auto bg-surface">{children}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<ConversationsProvider>
|
||||
<AssistantRuntimeProvider>
|
||||
<div className="app-shell flex h-dvh overflow-hidden bg-surface">
|
||||
<AppSidebar collapsed={collapsed} onToggleCollapsed={toggleCollapsed} />
|
||||
|
||||
{mobileOpen ? (
|
||||
<div className="fixed inset-0 z-40 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-ink/40"
|
||||
aria-label="بستن منو"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 shadow-xl">
|
||||
<AppSidebar overlay onNavigate={() => setMobileOpen(false)} collapsed={false} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="app-shell-column flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-line px-3 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-2 text-ink-muted hover:bg-surface-subtle hover:text-ink"
|
||||
aria-label="باز کردن منو"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="truncate text-sm font-medium text-ink">{pageTitleFromPath(pathname)}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`app-scroll min-w-0 flex-1 overscroll-contain ${
|
||||
lockPageScroll ? 'overflow-hidden' : 'overflow-x-hidden overflow-y-auto'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
</ConversationsProvider>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<MemoryRouter>
|
||||
<AppSidebar collapsed={false} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<AppSidebar collapsed={false} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<aside
|
||||
className={`${overlay ? 'flex' : 'hidden md:flex'} h-full shrink-0 flex-col border-l border-line bg-panel`}
|
||||
style={{ width }}
|
||||
>
|
||||
<div className={`flex shrink-0 items-center gap-2 border-b border-line px-3 py-3 ${compact ? 'flex-col' : ''}`}>
|
||||
{compact && onToggleCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="باز کردن منو"
|
||||
onClick={onToggleCollapsed}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl text-ink-muted transition hover:bg-surface-subtle hover:text-ink"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4 rotate-180" />
|
||||
</button>
|
||||
) : null}
|
||||
<NavLink
|
||||
to="/assistant"
|
||||
onClick={onNavigate}
|
||||
className={`flex min-w-0 items-center gap-2.5 rounded-xl px-1 py-1 ${compact ? 'justify-center' : 'flex-1'}`}
|
||||
>
|
||||
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-violet-500 text-sm font-bold text-white shadow-[0_8px_18px_-10px_rgba(37,99,235,0.8)]">
|
||||
K
|
||||
</span>
|
||||
{!compact ? (
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-bold tracking-tight text-ink">Kamidso</span>
|
||||
<span className="block text-[11px] text-ink-muted">دستیار هوشمند داده</span>
|
||||
</span>
|
||||
) : null}
|
||||
</NavLink>
|
||||
{!compact && onToggleCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="جمع کردن منو"
|
||||
onClick={onToggleCollapsed}
|
||||
className="hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg text-ink-muted transition hover:bg-surface-subtle hover:text-ink lg:inline-flex"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`shrink-0 px-2.5 pt-3 ${compact ? 'flex justify-center' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNewChat}
|
||||
className={`flex items-center gap-2 rounded-xl text-sm font-medium transition ${
|
||||
compact
|
||||
? 'h-10 w-10 justify-center border border-line bg-surface-subtle text-ink-muted hover:bg-surface hover:text-ink'
|
||||
: 'h-10 w-full border border-line bg-surface-subtle px-3 text-ink hover:border-line-strong hover:bg-white hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
<MessageSquarePlus className="h-4 w-4 shrink-0" />
|
||||
{!compact ? <span>گفتگوی جدید</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="shrink-0 space-y-1 px-2.5 py-3">
|
||||
{PRIMARY_NAV.map((item) => {
|
||||
const Icon = ICONS[item.id as keyof typeof ICONS]
|
||||
const active = isNavActive(pathname, item)
|
||||
return (
|
||||
<NavLink
|
||||
key={item.id}
|
||||
to={
|
||||
item.id === 'assistant' && pathname.startsWith('/assistant') && searchParams.toString()
|
||||
? `/assistant?${searchParams.toString()}`
|
||||
: item.to
|
||||
}
|
||||
end={item.end}
|
||||
title={item.label}
|
||||
onClick={onNavigate}
|
||||
className={navClass(active, compact)}
|
||||
>
|
||||
{active && !compact ? (
|
||||
<span className="absolute inset-y-2 right-0 w-[3px] rounded-full bg-accent" aria-hidden="true" />
|
||||
) : null}
|
||||
<Icon className="h-[18px] w-[18px] shrink-0" />
|
||||
{!compact ? <span className="truncate">{item.label}</span> : null}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{!compact ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden px-2.5 pb-2">
|
||||
<p className="shrink-0 px-3 pb-1.5 pt-1 text-[11px] font-semibold tracking-wide text-ink-muted">گفتگوها</p>
|
||||
<div
|
||||
ref={listRef}
|
||||
data-testid="conversation-list"
|
||||
className="sidebar-scroll min-h-0 flex-1"
|
||||
onScroll={(event) => {
|
||||
void loadMoreIfNeeded(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-scroll-inner pr-1">
|
||||
{items.length ? (
|
||||
items.map((item) => {
|
||||
const id = item.id || item.conversation_id
|
||||
const active = pathname.startsWith('/assistant') && id === activeConvId
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => goConversation(id)}
|
||||
className={`mb-0.5 h-9 w-full rounded-xl px-3 text-start text-[13px] leading-5 transition ${
|
||||
active
|
||||
? 'bg-accent-soft font-medium text-accent'
|
||||
: 'text-ink-muted hover:bg-surface-subtle hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
<span className="line-clamp-1">{item.title || 'گفتگوی بدون عنوان'}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="px-3 py-2 text-xs text-ink-muted">{loading ? 'در حال بارگذاری…' : 'هنوز گفتگویی نیست'}</p>
|
||||
)}
|
||||
{loading && items.length ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-ink-subtle" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1" />
|
||||
)}
|
||||
|
||||
<div className="shrink-0 border-t border-line p-2.5">
|
||||
<NavLink
|
||||
to={SETTINGS_NAV.to}
|
||||
title={SETTINGS_NAV.label}
|
||||
onClick={onNavigate}
|
||||
className={`mb-1.5 ${navClass(isNavActive(pathname, SETTINGS_NAV), compact)}`}
|
||||
>
|
||||
<Settings className="h-[18px] w-[18px] shrink-0" />
|
||||
{!compact ? <span>{SETTINGS_NAV.label}</span> : null}
|
||||
</NavLink>
|
||||
|
||||
{compact ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<NavLink
|
||||
to="/settings"
|
||||
title={user?.email || 'حساب کاربری'}
|
||||
aria-label="حساب کاربری"
|
||||
onClick={onNavigate}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-accent to-violet-500 text-[10px] font-semibold text-white"
|
||||
>
|
||||
{(user?.email || 'K').slice(0, 1).toUpperCase()}
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={askLogout}
|
||||
title="خروج"
|
||||
aria-label="خروج"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl text-ink-muted transition hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-line bg-surface-subtle p-2">
|
||||
<NavLink
|
||||
to="/settings"
|
||||
title={user?.email || 'حساب کاربری'}
|
||||
aria-label="حساب کاربری"
|
||||
onClick={onNavigate}
|
||||
className="flex items-center gap-2 rounded-lg px-1 py-1 text-ink transition hover:bg-white"
|
||||
>
|
||||
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent to-violet-500 text-[11px] font-semibold text-white">
|
||||
{(user?.email || 'K').slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-start">
|
||||
<span className="block truncate text-xs font-medium">{user?.email || 'حساب کاربری'}</span>
|
||||
<span className="block text-[10px] text-ink-subtle">حساب کاربری</span>
|
||||
</span>
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={askLogout}
|
||||
className="mt-1 flex h-8 w-full items-center justify-center gap-1.5 rounded-lg text-[12px] font-medium text-ink-muted transition hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{logoutDialog}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t border-line bg-panel/95 px-1 pb-[env(safe-area-inset-bottom)] pt-1 shadow-[0_-8px_24px_-16px_rgba(23,32,51,0.35)] backdrop-blur md:hidden">
|
||||
{items.map((item) => {
|
||||
const Icon = ICONS[item.id]
|
||||
const active = isNavActive(pathname, item)
|
||||
return (
|
||||
<NavLink
|
||||
key={item.id}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={`flex flex-col items-center gap-1 rounded-xl px-1 pb-2 pt-1.5 text-[10px] transition ${
|
||||
active ? 'text-accent' : 'text-ink-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-7 w-12 items-center justify-center rounded-full transition ${
|
||||
active ? 'bg-accent-soft' : ''
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-[18px] w-[18px]" />
|
||||
</span>
|
||||
{item.label.split(' ')[0]}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<aside
|
||||
className={`${overlay ? 'flex' : 'hidden md:flex'} h-full shrink-0 flex-col border-l border-line bg-panel`}
|
||||
style={{ width }}
|
||||
>
|
||||
<div className="flex h-16 shrink-0 items-center gap-2 border-b border-line px-3">
|
||||
<NavLink
|
||||
to="/home"
|
||||
className={`flex min-w-0 flex-1 items-center gap-2.5 rounded-xl px-1 py-1 ${collapsed ? 'justify-center' : ''}`}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-violet-500 text-sm font-bold text-white shadow-[0_8px_18px_-10px_rgba(37,99,235,0.8)]">
|
||||
K
|
||||
</span>
|
||||
{!collapsed ? (
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-bold tracking-tight text-ink">Kamidso</span>
|
||||
<span className="block text-[11px] text-ink-muted">دستیار هوشمند تحلیل داده</span>
|
||||
</span>
|
||||
) : null}
|
||||
</NavLink>
|
||||
{!collapsed && onToggleCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="جمع کردن منو"
|
||||
title="جمع کردن منو"
|
||||
onClick={onToggleCollapsed}
|
||||
className="hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg text-ink-muted transition hover:bg-surface-subtle hover:text-ink lg:inline-flex"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto px-2.5 py-3">
|
||||
{PRIMARY_NAV.map((item) => {
|
||||
const Icon = ICONS[item.id]
|
||||
const active = isNavActive(pathname, item)
|
||||
return (
|
||||
<NavLink
|
||||
key={item.id}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
title={item.label}
|
||||
onClick={onNavigate}
|
||||
className={`group relative flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition ${
|
||||
active
|
||||
? 'bg-accent-soft font-medium text-accent'
|
||||
: 'text-ink-muted hover:bg-surface-subtle hover:text-ink'
|
||||
} ${collapsed ? 'justify-center px-2' : ''}`}
|
||||
>
|
||||
{active ? (
|
||||
<span
|
||||
className="absolute inset-y-2 right-0 w-[3px] rounded-full bg-accent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<Icon className="h-[18px] w-[18px] shrink-0" />
|
||||
{!collapsed ? <span className="truncate">{item.label}</span> : null}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{collapsed && onToggleCollapsed ? (
|
||||
<div className="px-2.5 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="باز کردن منو"
|
||||
title="باز کردن منو"
|
||||
onClick={onToggleCollapsed}
|
||||
className="hidden h-9 w-full items-center justify-center rounded-xl text-ink-muted transition hover:bg-surface-subtle hover:text-ink lg:inline-flex"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<header className="relative z-20 flex h-16 shrink-0 items-center justify-between gap-4 border-b border-line bg-panel/90 px-4 backdrop-blur md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl p-2 text-ink-muted transition hover:bg-surface-subtle hover:text-ink md:hidden"
|
||||
aria-label="باز کردن منو"
|
||||
onClick={onMenu}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
{hideTitle ? null : (
|
||||
<h1 className="truncate text-base font-semibold text-ink">{title}</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{orgLabel ? (
|
||||
<span className="hidden max-w-[11rem] truncate text-xs font-medium text-ink sm:block" title={orgLabel}>
|
||||
{orgLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<Link
|
||||
to="/settings"
|
||||
className="hidden max-w-[10rem] truncate text-xs text-ink-muted transition hover:text-ink sm:block"
|
||||
title={user?.email}
|
||||
>
|
||||
{user?.email}
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-accent to-violet-500 text-sm font-semibold text-white shadow-[0_8px_16px_-10px_rgba(37,99,235,0.8)]"
|
||||
title={user?.email || 'حساب کاربری'}
|
||||
aria-label="حساب کاربری"
|
||||
>
|
||||
{(user?.email || 'K').slice(0, 1).toUpperCase()}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={ask}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-line bg-white px-2.5 text-xs font-medium text-ink-muted transition hover:bg-danger-soft hover:text-danger"
|
||||
title="خروج"
|
||||
aria-label="خروج"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">خروج</span>
|
||||
</button>
|
||||
</div>
|
||||
{dialog}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -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<MenuPos | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement | null>(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(
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
style={{
|
||||
top: pos.openUp ? undefined : pos.top,
|
||||
bottom: pos.openUp ? window.innerHeight - pos.top : undefined,
|
||||
left: pos.left,
|
||||
minWidth: 188,
|
||||
}}
|
||||
className={`fixed z-[9999] p-1.5 ${selectMenuPanelClass}`}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
item.onSelect()
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-right text-sm transition disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
item.tone === 'danger'
|
||||
? 'text-danger hover:bg-danger-soft'
|
||||
: 'text-ink hover:bg-surface-subtle'
|
||||
}`}
|
||||
>
|
||||
{Icon ? <Icon className="h-4 w-4 shrink-0" /> : null}
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-lg border bg-white text-ink-muted transition hover:border-line-strong hover:text-ink ${
|
||||
open ? 'border-accent/40 text-ink' : 'border-line'
|
||||
}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
{menu}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<CollapsibleContextValue | null>(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 (
|
||||
<CollapsibleContext.Provider value={{ open, toggle, onOpenChange }}>
|
||||
<div data-state={open ? 'open' : 'closed'} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
children,
|
||||
className = '',
|
||||
}: PropsWithChildren<{ className?: string }>) {
|
||||
const { toggle, open } = useCollapsible()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
toggle()
|
||||
}}
|
||||
className={`w-full text-start ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
children,
|
||||
className = '',
|
||||
}: PropsWithChildren<{ className?: string }>) {
|
||||
const { open } = useCollapsible()
|
||||
if (!open) return null
|
||||
return <div className={className}>{children}</div>
|
||||
}
|
||||
|
||||
function CollapsibleArrow({ className = '' }: { className?: string }) {
|
||||
const { open } = useCollapsible()
|
||||
return (
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 shrink-0 transition-transform duration-200 ${open ? 'rotate-180' : ''} ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const Collapsible = Object.assign(CollapsibleRoot, {
|
||||
Trigger: CollapsibleTrigger,
|
||||
Content: CollapsibleContent,
|
||||
Arrow: CollapsibleArrow,
|
||||
})
|
||||
@@ -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 (
|
||||
<Modal open={open} title={title} size="sm" onClose={onCancel}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${
|
||||
danger ? 'bg-danger-soft text-danger' : 'bg-accent-soft text-accent'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<p className="pt-1 text-sm leading-7 text-ink-muted">{message}</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onCancel} disabled={busy}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm} disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLDivElement | null>(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 (
|
||||
<div className="fixed inset-0 z-50 print:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بستن"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-ink/40 backdrop-blur-[2px]"
|
||||
/>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
className={`drawer-enter absolute inset-y-0 ${
|
||||
side === 'end' ? 'end-0' : 'start-0'
|
||||
} flex ${width} flex-col bg-panel shadow-2xl outline-none`}
|
||||
>
|
||||
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-line px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold text-ink">{title}</h2>
|
||||
{description ? (
|
||||
<p className="mt-1 text-xs leading-5 text-ink-muted">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{headerExtra}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بستن"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-ink-muted transition hover:bg-surface-subtle hover:text-ink"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||
|
||||
{footer ? (
|
||||
<div className="shrink-0 border-t border-line bg-surface-subtle/60 px-5 py-3">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col items-center rounded-3xl border border-dashed border-line bg-panel px-6 py-16 text-center">
|
||||
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-accent to-violet-500 text-white shadow-[0_10px_24px_-12px_rgba(37,99,235,0.7)]">
|
||||
<Icon className="h-6 w-6" />
|
||||
</span>
|
||||
<h2 className="mt-5 text-base font-semibold text-ink">{title}</h2>
|
||||
<p className="mt-2 max-w-md text-sm leading-7 text-ink-muted">{description}</p>
|
||||
{actionTo && actionLabel ? (
|
||||
<Link to={actionTo} className="mt-6">
|
||||
<Button>{actionLabel}</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
{children ? <div className="mt-6">{children}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
|
||||
export function FeatureTabs({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{ to: string; label: string; end?: boolean }>
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5 inline-flex gap-1 rounded-2xl border border-line bg-surface p-1">
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`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}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={`min-w-0 ${className}`}>
|
||||
{label ? (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="mb-1.5 flex items-center gap-1 text-[12px] font-medium text-ink"
|
||||
>
|
||||
{label}
|
||||
{required ? (
|
||||
<span className="text-danger" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
) : null}
|
||||
{children}
|
||||
{error ? (
|
||||
<p className="mt-1.5 flex items-start gap-1 text-[11px] leading-5 text-danger">
|
||||
<AlertCircle className="mt-0.5 h-3 w-3 shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
) : hint ? (
|
||||
<p className="mt-1.5 text-[11px] leading-5 text-ink-muted">{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
invalid?: boolean
|
||||
leadingIcon?: ReactNode
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ className = '', invalid, leadingIcon, ...rest },
|
||||
ref,
|
||||
) {
|
||||
if (leadingIcon) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-ink-subtle">
|
||||
{leadingIcon}
|
||||
</span>
|
||||
<input
|
||||
ref={ref}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={controlClass(invalid, `h-10 pr-9 pl-3 ${className}`)}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={controlClass(invalid, `h-10 px-3 ${className}`)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & { invalid?: boolean }
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
|
||||
{ className = '', invalid, rows = 3, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={rows}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={controlClass(invalid, `resize-y px-3 py-2.5 leading-7 ${className}`)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
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 (
|
||||
<SelectMenu
|
||||
fullWidth
|
||||
size="md"
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
ariaLabel={ariaLabel}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
invalid={invalid}
|
||||
matchTriggerWidth
|
||||
minMenuWidth={180}
|
||||
maxMenuWidth={420}
|
||||
triggerClassName={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Label + control in one call for the common case. */
|
||||
export function LabeledInput({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required,
|
||||
className,
|
||||
...rest
|
||||
}: InputProps & Pick<FieldProps, 'label' | 'hint' | 'error' | 'className'>) {
|
||||
const id = useId()
|
||||
return (
|
||||
<Field
|
||||
label={label}
|
||||
hint={hint}
|
||||
error={error}
|
||||
required={required}
|
||||
htmlFor={id}
|
||||
className={className}
|
||||
>
|
||||
<Input id={id} invalid={Boolean(error)} required={required} {...rest} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export function LabeledTextarea({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required,
|
||||
className,
|
||||
...rest
|
||||
}: TextareaProps & Pick<FieldProps, 'label' | 'hint' | 'error' | 'className'>) {
|
||||
const id = useId()
|
||||
return (
|
||||
<Field
|
||||
label={label}
|
||||
hint={hint}
|
||||
error={error}
|
||||
required={required}
|
||||
htmlFor={id}
|
||||
className={className}
|
||||
>
|
||||
<Textarea id={id} invalid={Boolean(error)} required={required} {...rest} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export function LabeledSelect({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required,
|
||||
className,
|
||||
...rest
|
||||
}: SelectProps & Pick<FieldProps, 'label' | 'hint' | 'error' | 'className'>) {
|
||||
return (
|
||||
<Field label={label} hint={hint} error={error} required={required} className={className}>
|
||||
<Select invalid={Boolean(error)} required={required} {...rest} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export type { SelectMenuOption }
|
||||
@@ -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 (
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3.5">
|
||||
{Icon ? (
|
||||
<span className="mt-0.5 inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-accent to-violet-500 text-white shadow-[0_10px_24px_-12px_rgba(37,99,235,0.7)]">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
{kicker ? (
|
||||
<p className="mb-1 text-[11px] font-medium text-ink-muted">{kicker}</p>
|
||||
) : null}
|
||||
<h1 className="text-2xl font-bold leading-tight tracking-tight text-ink">{title}</h1>
|
||||
{subtitle ? (
|
||||
<p className="mt-1.5 max-w-2xl text-sm leading-6 text-ink-muted">{subtitle}</p>
|
||||
) : null}
|
||||
{meta ? <div className="mt-2.5 flex flex-wrap items-center gap-2">{meta}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLElement> & {
|
||||
children: ReactNode
|
||||
width?: keyof typeof WIDTHS
|
||||
}
|
||||
|
||||
export function PageShell({
|
||||
children,
|
||||
width = 'lg',
|
||||
className = '',
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<main
|
||||
className={`page-enter mx-auto ${WIDTHS[width]} px-4 py-7 md:px-6 ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export function SegmentedTabs<T extends string>({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
className = '',
|
||||
}: {
|
||||
items: ReadonlyArray<readonly [T, string]>
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex gap-1 rounded-xl border border-line bg-surface-subtle p-1 ${className}`}
|
||||
>
|
||||
{items.map(([id, label]) => {
|
||||
const active = value === id
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onChange(id)}
|
||||
className={`rounded-lg px-3.5 py-1.5 text-sm transition ${
|
||||
active
|
||||
? 'bg-panel font-medium text-ink shadow-sm'
|
||||
: 'text-ink-muted hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<MenuPos | null>(null)
|
||||
const [highlight, setHighlight] = useState(-1)
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement | null>(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<HTMLElement>(`[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<HTMLButtonElement>) {
|
||||
if (disabled) return
|
||||
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
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(
|
||||
<div
|
||||
ref={menuRef}
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={-1}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
style={{
|
||||
top: pos.openUp ? undefined : pos.top,
|
||||
bottom: pos.openUp ? window.innerHeight - pos.top : undefined,
|
||||
left: pos.left,
|
||||
width: pos.width,
|
||||
maxHeight: pos.maxHeight,
|
||||
}}
|
||||
className={`fixed z-[9999] flex flex-col ${PANEL_CLASS}`}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5">
|
||||
{options.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-ink-muted">{emptyLabel}</p>
|
||||
) : (
|
||||
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 (
|
||||
<div key={option.value || `opt-${index}`}>
|
||||
{showGroup ? (
|
||||
<p className={`px-2.5 pb-1 text-[11px] font-medium text-ink-subtle ${index === 0 ? 'pt-0.5' : 'pt-2'}`}>
|
||||
{option.group}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
data-index={index}
|
||||
aria-selected={isSelected}
|
||||
disabled={option.disabled}
|
||||
onMouseEnter={() => {
|
||||
if (!option.disabled) setHighlight(index)
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!option.disabled) selectValue(option.value)
|
||||
}}
|
||||
className={`flex w-full items-start gap-2.5 rounded-xl px-2.5 py-2 text-right transition disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
isActive ? 'bg-accent-soft' : 'hover:bg-surface-subtle'
|
||||
} ${isSelected ? 'text-accent' : 'text-ink'}`}
|
||||
>
|
||||
{Icon ? (
|
||||
<span
|
||||
className={`mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${
|
||||
isSelected ? 'bg-accent text-white' : 'bg-surface-subtle text-ink-muted'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="mt-0.5 block truncate text-[11px] text-ink-subtle">
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
{isSelected ? <Check className="h-4 w-4 text-accent" /> : null}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="border-t border-line bg-surface-subtle/60 px-2 py-2">{footer}</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={`relative ${fullWidth ? 'w-full' : 'inline-flex'}`}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled) setOpen((prev) => !prev)
|
||||
}}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
className={`inline-flex ${iconOnly ? 'h-8 w-8 justify-center rounded-full px-0' : `${heightClass} ${shapeClass}`} items-center border ${isChip ? '' : 'bg-white'} ${textClass} transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
fullWidth && !iconOnly ? 'w-full' : ''
|
||||
} ${
|
||||
invalid
|
||||
? 'border-danger/50'
|
||||
: open
|
||||
? 'border-accent/45 shadow-[0_0_0_3px_rgba(37,99,235,0.08)]'
|
||||
: 'border-line hover:border-line-strong'
|
||||
} ${triggerClassName}`}
|
||||
>
|
||||
{leading}
|
||||
{SelectedIcon && !leading ? (
|
||||
<SelectedIcon className="h-4 w-4 shrink-0 text-ink-muted" />
|
||||
) : null}
|
||||
{iconOnly ? (
|
||||
<span className="sr-only">{triggerLabel || ariaLabel}</span>
|
||||
) : (
|
||||
<span className={`min-w-0 flex-1 truncate text-right ${triggerMuted ? 'text-ink-muted' : 'text-ink'}`}>
|
||||
{triggerLabel}
|
||||
</span>
|
||||
)}
|
||||
{trailing}
|
||||
{iconOnly ? null : (
|
||||
<ChevronDown
|
||||
className={`${isChip ? 'h-3.5 w-3.5' : 'h-4 w-4'} shrink-0 text-ink-muted/80 transition ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{menu}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const selectMenuPanelClass = PANEL_CLASS
|
||||
@@ -0,0 +1,48 @@
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded-xl bg-line/60 ${className}`} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 6 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-line bg-panel">
|
||||
<div className="border-b border-line bg-surface-subtle px-4 py-3">
|
||||
<Skeleton className="h-3 w-64" />
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<div key={index} className="flex items-center gap-4 border-b border-line px-4 py-3.5 last:border-b-0">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="ms-auto h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardGridSkeleton({
|
||||
count = 4,
|
||||
className = 'grid gap-4 md:grid-cols-2 xl:grid-cols-3',
|
||||
}: {
|
||||
count?: number
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div key={index} className="rounded-2xl border border-line bg-panel p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="mt-4 h-3 w-28" />
|
||||
<Skeleton className="mt-2 h-3 w-36" />
|
||||
<div className="mt-6 flex gap-2">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warn' | 'danger'
|
||||
|
||||
const TONES: Record<BadgeTone, string> = {
|
||||
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 (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 ring-inset ${TONES[tone]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_TONES: Record<string, BadgeTone> = {
|
||||
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<string, string> = {
|
||||
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 <Badge tone={STATUS_TONES[status] || 'neutral'}>{label || LABELS[status] || status}</Badge>
|
||||
}
|
||||
@@ -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 = (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={showLabel ? undefined : label}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex ${style.track} shrink-0 items-center rounded-full transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
checked ? 'bg-accent' : 'bg-line-strong'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute right-1 ${style.thumb} rounded-full bg-white shadow-sm transition-transform ${
|
||||
checked ? style.on : style.off
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
|
||||
if (!showLabel) return control
|
||||
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
{control}
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[13px] font-medium text-ink">{label}</span>
|
||||
{description ? (
|
||||
<span className="mt-0.5 block text-[11px] leading-5 text-ink-muted">{description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-50 flex justify-center px-4 print:hidden">
|
||||
<div
|
||||
role="status"
|
||||
className="pointer-events-auto flex items-center gap-3 rounded-xl border border-line bg-panel px-4 py-2.5 shadow-lg"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-success" />
|
||||
<span className="text-sm text-ink">{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بستن پیام"
|
||||
onClick={onDismiss}
|
||||
className="rounded-md p-1 text-ink-muted transition hover:bg-surface-subtle hover:text-ink"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<PushOptions, 'tone'>) => void
|
||||
error: (message: string, options?: Omit<PushOptions, 'tone'>) => void
|
||||
dismiss: (id: number) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null)
|
||||
|
||||
const TONES: Record<ToastTone, { icon: typeof CheckCircle2; className: string }> = {
|
||||
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<ToastItem[]>([])
|
||||
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<ToastApi>(
|
||||
() => ({
|
||||
push,
|
||||
dismiss,
|
||||
success: (message, options) => push(message, { ...options, tone: 'success' }),
|
||||
error: (message, options) => push(message, { ...options, tone: 'error', duration: 6000 }),
|
||||
}),
|
||||
[push, dismiss],
|
||||
)
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-[9998] flex flex-col items-center gap-2 px-4 print:hidden">
|
||||
{items.map((item) => {
|
||||
const { icon: Icon, className } = TONES[item.tone]
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
role="status"
|
||||
className="toast-enter pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border border-line bg-panel px-4 py-2.5 shadow-[0_16px_40px_-16px_rgba(23,32,51,0.5)]"
|
||||
>
|
||||
<Icon className={`h-4 w-4 shrink-0 ${className}`} />
|
||||
<span className="min-w-0 flex-1 text-sm leading-6 text-ink">{item.message}</span>
|
||||
{item.action ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
item.action?.onSelect()
|
||||
dismiss(item.id)
|
||||
}}
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-accent transition hover:bg-accent-soft"
|
||||
>
|
||||
{item.action.label}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بستن پیام"
|
||||
onClick={() => dismiss(item.id)}
|
||||
className="shrink-0 rounded-md p-1 text-ink-subtle transition hover:bg-surface-subtle hover:text-ink"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2 rounded-2xl border border-line bg-panel/80 p-2.5 shadow-[0_1px_2px_rgba(23,32,51,0.03)]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolbarSpacer() {
|
||||
return <div className="flex-1" />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="shrink-0 text-[11px] font-medium text-ink-muted">{label}</span>
|
||||
<SelectMenu
|
||||
size="sm"
|
||||
ariaLabel={ariaLabel}
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
minMenuWidth={180}
|
||||
maxMenuWidth={280}
|
||||
matchTriggerWidth={false}
|
||||
triggerClassName={`${minWidth} ${
|
||||
active ? 'border-accent/40 bg-accent-soft/30' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchField({
|
||||
ariaLabel,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
onCommit,
|
||||
}: {
|
||||
ariaLabel: string
|
||||
value: string
|
||||
placeholder?: string
|
||||
onChange: (value: string) => void
|
||||
onCommit: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-muted/70" />
|
||||
<input
|
||||
type="search"
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => 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 ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="پاک کردن جستجو"
|
||||
onClick={() => {
|
||||
onChange('')
|
||||
onCommit('')
|
||||
}}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-ink-muted transition hover:bg-surface-subtle hover:text-ink"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLSpanElement | null>(null)
|
||||
const bubbleRef = useRef<HTMLDivElement | null>(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 (
|
||||
<>
|
||||
<span
|
||||
ref={wrapperRef}
|
||||
className="contents"
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onFocusCapture={show}
|
||||
onBlurCapture={hide}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{open
|
||||
? createPortal(
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
id={id}
|
||||
role="tooltip"
|
||||
style={{
|
||||
top: pos?.top ?? -9999,
|
||||
left: pos?.left ?? -9999,
|
||||
visibility: pos ? 'visible' : 'hidden',
|
||||
}}
|
||||
className="pointer-events-none fixed z-[10000] max-w-[15rem] rounded-lg bg-ink px-2.5 py-1.5 text-[11px] font-medium leading-5 text-white shadow-lg"
|
||||
>
|
||||
{label}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLElement | null>, active: boolean) {
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const container = ref.current
|
||||
if (!container) return
|
||||
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null
|
||||
const initial = container.querySelector<HTMLElement>(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<HTMLElement>(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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface KamidsoError {
|
||||
code: string
|
||||
message: string
|
||||
retryable?: boolean
|
||||
run_id?: string
|
||||
}
|
||||
|
||||
export const ERROR_MESSAGES: Record<string, string> = {
|
||||
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<string, unknown>
|
||||
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: 'خطایی رخ داد.' }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string | TableColumn>
|
||||
rows: unknown[]
|
||||
}
|
||||
|
||||
export interface ChartSeries {
|
||||
field: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface ChartBlock extends ReportBlockBase {
|
||||
type: 'chart'
|
||||
chartType?: ChartType
|
||||
title?: string
|
||||
dataset?: string
|
||||
data?: Record<string, unknown>[]
|
||||
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<string, unknown>[] | { ref: string }
|
||||
|
||||
export interface KamidsoReport {
|
||||
version: number
|
||||
report_id?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
description?: string
|
||||
locale?: string
|
||||
summary?: string
|
||||
datasets?: Record<string, ReportDataset>
|
||||
blocks: ReportBlock[]
|
||||
metadata?: Record<string, unknown>
|
||||
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<string, unknown>[] {
|
||||
if (!datasetName || !report.datasets) return []
|
||||
const entry = report.datasets[datasetName]
|
||||
if (Array.isArray(entry)) return entry as Record<string, unknown>[]
|
||||
return []
|
||||
}
|
||||
|
||||
export function resolveChartData(block: ChartBlock, report?: KamidsoReport): Record<string, unknown>[] {
|
||||
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<string, unknown> {
|
||||
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<number, string> = {
|
||||
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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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('بازه')
|
||||
})
|
||||
})
|
||||
@@ -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: 'اسناد' })
|
||||
})
|
||||
})
|
||||
@@ -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<ChatMessage['attachedDatabases']> {
|
||||
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<ChatMessage['attachedDatabases']>
|
||||
knowledge?: ChatMessage['attachedKnowledge']
|
||||
} {
|
||||
const files = collectTurnInputFiles(messages, [])
|
||||
const databases: Array<{ id?: string; db_name: string; db_type: string }> = []
|
||||
const seen = new Set<string>()
|
||||
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<string, SessionFileSnapshot>()
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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('تفکر / اقدام / مشاهده')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { t } from '../../../i18n'
|
||||
|
||||
const ENGINE_LABELS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
'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
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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',
|
||||
})
|
||||
}
|
||||
@@ -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<ConversationListPage> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<ConversationHistoryResponse> {
|
||||
return apiFetch<ConversationHistoryResponse>(
|
||||
`/conversations/${encodeURIComponent(conversationId)}`,
|
||||
)
|
||||
}
|
||||
@@ -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<HtmlShare> {
|
||||
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<SharedHtmlReport> {
|
||||
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 ?? ''),
|
||||
}
|
||||
}
|
||||
@@ -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<string[]> {
|
||||
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<ResourceOption[]> {
|
||||
try {
|
||||
const rows = await listDatasourceRecords()
|
||||
return rows.map(toResourceOption)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function listKnowledgeSpaces(): Promise<ResourceOption[]> {
|
||||
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<ResourceOption[]> {
|
||||
try {
|
||||
const data = await apiFetchEnvelope<unknown>('/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<string, unknown>
|
||||
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 []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { apiFetch, kamidsoUrl } from '../../../api/client'
|
||||
import type { KamidsoRun } from '../../../contracts/run'
|
||||
|
||||
export async function getRun(runId: string): Promise<KamidsoRun> {
|
||||
return apiFetch<KamidsoRun>(`/runs/${runId}`)
|
||||
}
|
||||
|
||||
export async function streamRunEvents(
|
||||
runId: string,
|
||||
after: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
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
|
||||
}
|
||||
@@ -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 <CodePreview code={String(artifact.content)} language="python" />
|
||||
case 'table':
|
||||
return <TablePreview table={artifact.content as { columns?: string[]; rows?: unknown[][] }} />
|
||||
case 'chart':
|
||||
return <ChartPreview spec={artifact.content as Record<string, unknown>} />
|
||||
case 'html':
|
||||
return <HtmlPreview html={String(artifact.content)} />
|
||||
case 'markdown':
|
||||
return <MarkdownAnswer content={String(artifact.content)} />
|
||||
case 'json':
|
||||
return <JsonPreview data={artifact.content} />
|
||||
default:
|
||||
return <JsonPreview data={artifact.content} />
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>): {
|
||||
chartType: string
|
||||
data: Record<string, unknown>[]
|
||||
xKey: string
|
||||
series: SeriesItem[]
|
||||
stacked: boolean
|
||||
showGrid: boolean
|
||||
height?: number
|
||||
} {
|
||||
const data = (spec.data as Record<string, unknown>[]) || []
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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 (
|
||||
<p className="rounded-xl bg-surface-subtle px-3 py-6 text-center text-xs text-ink-muted">
|
||||
برای این بازه زمانی دادهای موجود نیست.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey={yKey}
|
||||
nameKey={xKey}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={inner}
|
||||
outerRadius={compact ? 68 : 90}
|
||||
>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={index} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip {...tooltip} />
|
||||
{showLegend ? <Legend /> : null}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (chartType === 'scatter') {
|
||||
const xField = series[0]?.field || xKey
|
||||
const yField = series[1]?.field || series[0]?.field || 'value'
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ScatterChart margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
{showGrid ? <CartesianGrid strokeDasharray="3 3" stroke={grid} /> : null}
|
||||
<XAxis dataKey={xField} type="number" tick={tick} name={xField} />
|
||||
<YAxis dataKey={yField} type="number" tick={tick} name={yField} width={48} />
|
||||
<ZAxis range={[60, 60]} />
|
||||
<Tooltip {...tooltip} />
|
||||
<Scatter data={data} fill={COLORS[0]} />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (chartType === 'area') {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
{showGrid ? <CartesianGrid strokeDasharray="3 3" stroke={grid} /> : null}
|
||||
<XAxis dataKey={xKey} tick={tick} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={tick} axisLine={false} tickLine={false} width={48} />
|
||||
<Tooltip {...tooltip} />
|
||||
{showLegend ? <Legend /> : null}
|
||||
{series.map((s, index) => (
|
||||
<Area
|
||||
key={s.field}
|
||||
type="monotone"
|
||||
dataKey={s.field}
|
||||
name={s.label}
|
||||
stroke={COLORS[index % COLORS.length]}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
fillOpacity={0.2}
|
||||
stackId={stacked ? 'stack' : undefined}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (chartType === 'line') {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
{showGrid ? <CartesianGrid strokeDasharray="3 3" stroke={grid} /> : null}
|
||||
<XAxis dataKey={xKey} tick={tick} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={tick} axisLine={false} tickLine={false} width={48} />
|
||||
<Tooltip {...tooltip} />
|
||||
{showLegend ? <Legend /> : null}
|
||||
{series.map((s, index) => (
|
||||
<Line
|
||||
key={s.field}
|
||||
type="monotone"
|
||||
dataKey={s.field}
|
||||
name={s.label}
|
||||
stroke={COLORS[index % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
{showGrid ? <CartesianGrid strokeDasharray="3 3" stroke={grid} vertical={false} /> : null}
|
||||
<XAxis dataKey={xKey} tick={tick} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={tick} axisLine={false} tickLine={false} width={48} />
|
||||
<Tooltip {...tooltip} />
|
||||
{showLegend ? <Legend /> : null}
|
||||
{series.map((s, index) => (
|
||||
<Bar
|
||||
key={s.field}
|
||||
dataKey={s.field}
|
||||
name={s.label}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
radius={[6, 6, 0, 0]}
|
||||
stackId={stacked ? 'stack' : undefined}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
default: function Fallback({ spec }: ChartPreviewProps) {
|
||||
return (
|
||||
<pre className="ltr-island overflow-auto rounded-xl bg-surface-subtle p-3 text-xs">
|
||||
{JSON.stringify(spec, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export function ChartPreview({ spec, variant = 'code', compact = false }: ChartPreviewProps) {
|
||||
return (
|
||||
<Suspense fallback={<p className="text-xs text-ink-muted">بارگذاری نمودار…</p>}>
|
||||
<ChartInner spec={spec} variant={variant} compact={compact} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CodeBlock as CodePreview } from '../components/CodeBlock'
|
||||
@@ -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<string, unknown>
|
||||
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 (
|
||||
<a
|
||||
href={file.url}
|
||||
download={file.name}
|
||||
className={
|
||||
compact
|
||||
? 'inline-flex max-w-full items-center gap-2 rounded-xl border border-line bg-panel px-3 py-2 text-sm text-ink transition hover:border-accent/40 hover:bg-accent/5'
|
||||
: 'flex max-w-full items-center gap-3 rounded-xl border border-emerald-200/80 bg-emerald-50/80 px-3 py-2.5 text-ink transition hover:border-emerald-400 hover:bg-emerald-50 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/15'
|
||||
}
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300">
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
<p className="text-[11px] text-ink-muted">{t('assistant.panel.downloadFile')}</p>
|
||||
</div>
|
||||
<Download className="h-4 w-4 shrink-0 text-emerald-700 dark:text-emerald-300" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function JsonPreview({ data }: { data: unknown }) {
|
||||
return (
|
||||
<pre className="ltr-island max-h-80 overflow-auto rounded-xl bg-surface-subtle p-3 text-xs">
|
||||
{typeof data === 'string' ? data : JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
}) {
|
||||
const normalized = normalizeTableContent(table)
|
||||
if (!normalized) {
|
||||
return (
|
||||
<pre className="ltr-island overflow-auto rounded-lg bg-slate-900/50 p-3 text-xs text-slate-300">
|
||||
{JSON.stringify(table, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
const { columns, rows } = normalized
|
||||
|
||||
return (
|
||||
<div className="overflow-auto rounded-lg border border-slate-700/50">
|
||||
<table className="w-full min-w-max text-xs">
|
||||
<thead>
|
||||
<tr className="bg-slate-900">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="border-b border-slate-700/60 px-3 py-2 text-start font-medium text-slate-300"
|
||||
>
|
||||
{col.title}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, 100).map((row, rowIndex) => (
|
||||
<tr key={String(row.__rowKey ?? rowIndex)} className="odd:bg-slate-950/40 even:bg-slate-900/20">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="border-b border-slate-800/60 px-3 py-2 text-slate-200">
|
||||
{formatCell(row[col.dataIndex])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length > 100 ? (
|
||||
<p className="border-t border-slate-700/50 bg-slate-900 px-3 py-1.5 text-[11px] text-slate-400">
|
||||
{rows.length - 100}+ ردیف دیگر
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
export type NormalizedTableColumn = {
|
||||
key: string
|
||||
title: string
|
||||
dataIndex: string
|
||||
}
|
||||
|
||||
export type NormalizedTable = {
|
||||
columns: NormalizedTableColumn[]
|
||||
rows: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
/** 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<string, unknown>[] = rawRows.map((row, rowIndex) => {
|
||||
if (Array.isArray(row)) {
|
||||
const record: Record<string, unknown> = { __rowKey: rowIndex }
|
||||
columns.forEach((col, colIndex) => {
|
||||
record[col.dataIndex] = row[colIndex]
|
||||
})
|
||||
return record
|
||||
}
|
||||
if (row && typeof row === 'object') {
|
||||
return { ...(row as Record<string, unknown>), __rowKey: rowIndex }
|
||||
}
|
||||
return { __rowKey: rowIndex, value: row }
|
||||
})
|
||||
|
||||
return { columns, rows }
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`my-2 overflow-hidden rounded-xl border ${
|
||||
isExecution ? 'border-slate-700/60 bg-slate-950' : 'border-line bg-panel'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-between border-b px-3 py-1.5 ${
|
||||
isExecution ? 'border-slate-700/60 bg-slate-900' : 'border-line bg-surface-subtle'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-[11px] font-medium ${isExecution ? 'text-slate-400' : 'text-ink-muted'}`}>
|
||||
{label || language}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy()}
|
||||
className={isExecution ? 'text-slate-400 hover:text-slate-200' : 'text-ink-muted hover:text-accent'}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={style}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '12px',
|
||||
background: bg,
|
||||
maxHeight: maxHeight ?? undefined,
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
{terminal ? (
|
||||
<>
|
||||
<div className={`border-t ${isExecution ? 'border-slate-700/60' : 'border-line'}`} />
|
||||
<div className={`flex items-center gap-2 border-b px-3 py-1.5 text-[11px] font-medium ${
|
||||
isExecution ? 'border-slate-700/60 bg-slate-900 text-slate-300' : 'border-line bg-surface-subtle text-ink-muted'
|
||||
}`}>
|
||||
<span>{t('assistant.panel.terminal')}</span>
|
||||
{terminal.success === false ? (
|
||||
<X className="h-3.5 w-3.5 text-danger" />
|
||||
) : (
|
||||
<Check className="h-3.5 w-3.5 text-success" />
|
||||
)}
|
||||
</div>
|
||||
<pre
|
||||
className={`ltr-island overflow-auto whitespace-pre-wrap p-4 text-xs leading-relaxed ${
|
||||
isExecution ? 'bg-slate-950 font-mono text-emerald-400' : 'bg-surface-subtle text-ink'
|
||||
}`}
|
||||
style={{ maxHeight: maxHeight ?? 288 }}
|
||||
>
|
||||
{terminal.content}
|
||||
</pre>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-line bg-panel p-3 md:p-4">
|
||||
{resourceBar}
|
||||
<AttachmentRail
|
||||
drafts={drafts}
|
||||
legacyFiles={legacyFiles}
|
||||
onRemove={onRemoveFile}
|
||||
onRetry={onRetryFile}
|
||||
addControl={
|
||||
<AttachmentRailAddButton
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
}
|
||||
className="mb-2"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={DATA_FILE_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) onAddFiles(Array.from(e.target.files))
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder="سؤال تحلیلی خود را بنویسید…"
|
||||
rows={2}
|
||||
className="min-h-[52px] flex-1 resize-none rounded-xl border border-line bg-white px-3 py-2.5 text-sm text-ink outline-none transition focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (!loading) onSend()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{loading ? (
|
||||
<Button variant="secondary" onClick={onStop} aria-label="توقف">
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onSend} disabled={!query.trim() && drafts.length === 0}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="hidden w-11 shrink-0 border-l border-line bg-panel md:flex md:flex-col md:items-center md:gap-1 md:py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNew}
|
||||
className="rounded-lg p-2 text-accent hover:bg-accent-soft"
|
||||
aria-label="گفتگوی جدید"
|
||||
>
|
||||
<MessageSquarePlus className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="rounded-lg p-2 text-ink-muted hover:bg-surface-subtle"
|
||||
aria-label="باز کردن لیست گفتگو"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden w-60 shrink-0 flex-col border-l border-line bg-panel md:flex">
|
||||
<div className="flex items-center justify-between border-b border-line px-3 py-2">
|
||||
<span className="text-sm font-medium text-ink">گفتگوها</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button type="button" onClick={onNew} className="rounded-lg p-1.5 text-accent hover:bg-accent-soft">
|
||||
<MessageSquarePlus className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" onClick={onToggle} className="rounded-lg p-1.5 text-ink-muted hover:bg-surface-subtle">
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1.5">
|
||||
{items.map((item) => {
|
||||
const id = item.id || item.conversation_id
|
||||
const active = id === activeId
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onSelect(id)}
|
||||
className={`mb-0.5 w-full rounded-lg px-2.5 py-2 text-start text-xs transition ${
|
||||
active ? 'bg-accent-soft text-accent' : 'text-ink-muted hover:bg-surface-subtle hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
<span className="line-clamp-2">{item.title || 'گفتگوی بدون عنوان'}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof import('../report/downloadReportPdf')>(
|
||||
'../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 = "<html dir='rtl' lang='fa'><body><h1>گزارش</h1></body></html>"
|
||||
|
||||
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(<HtmlPreview html={sample} />)
|
||||
|
||||
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(<HtmlPreview html={sample} />)
|
||||
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(<HtmlPreview html={sample} />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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 <iframe title={title} sandbox="allow-scripts" className={className} srcDoc={html} />
|
||||
}
|
||||
|
||||
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<ShareAnchor | null>(null)
|
||||
const [shareBox, setShareBox] = useState<ShareBox | null>(null)
|
||||
const shareButtonRef = useRef<HTMLElement | null>(null)
|
||||
const [shareUrl, setShareUrl] = useState<string | null>(null)
|
||||
const [sharing, setSharing] = useState(false)
|
||||
const [shareError, setShareError] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const dialogRef = useRef<HTMLDivElement | null>(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 (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button type="button" className={actionClass} onClick={download}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{downloadLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={actionClass}
|
||||
data-report-share=""
|
||||
aria-expanded={shareOpen}
|
||||
onClick={(event) => void openShare(anchor, event.currentTarget)}
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" />
|
||||
{shareLabel}
|
||||
</button>
|
||||
{extra}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sharePanel =
|
||||
shareAnchor && shareBox
|
||||
? createPortal(
|
||||
<div
|
||||
data-report-share=""
|
||||
role="dialog"
|
||||
aria-label={shareLabel}
|
||||
style={{ top: shareBox.top, left: shareBox.left, width: shareBox.width }}
|
||||
className="fixed z-[90] rounded-xl border border-line bg-white p-3 text-start shadow-[0_16px_40px_-20px_rgba(15,23,42,0.45)]"
|
||||
>
|
||||
{sharing ? <p className="text-xs text-ink-muted">{t('assistant.report.sharing')}</p> : null}
|
||||
{shareError ? <p className="text-xs text-danger">{t('assistant.report.shareFailed')}</p> : null}
|
||||
{shareUrl ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="truncate text-xs text-ink-muted" dir="ltr">
|
||||
{shareUrl}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyLink()}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-2.5 text-xs font-medium text-white"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? t('assistant.report.linkCopied') : t('assistant.report.copyLink')}
|
||||
</button>
|
||||
<a
|
||||
href={shareUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-line px-2.5 text-xs font-medium text-ink"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('assistant.report.openPage')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
{sharePanel}
|
||||
<div className="overflow-hidden rounded-2xl border border-line bg-white shadow-[0_16px_40px_-28px_rgba(23,32,51,0.55)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-line bg-[linear-gradient(180deg,#fbfcfe_0%,#f3f6fb_100%)] px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="truncate text-xs font-semibold text-ink">{documentTitle}</span>
|
||||
</div>
|
||||
{toolbar(
|
||||
'inline',
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expandLabel}
|
||||
title={expandLabel}
|
||||
onClick={() => setExpanded(true)}
|
||||
className={actionClass}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
{expandLabel}
|
||||
</button>,
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-[#eef2f6] p-2 sm:p-3">
|
||||
<div className="overflow-hidden rounded-xl bg-white shadow-[0_1px_2px_rgba(15,23,42,0.06)] ring-1 ring-black/5">
|
||||
<ReportFrame
|
||||
html={html}
|
||||
title="html-preview"
|
||||
className="h-[min(70vh,720px)] min-h-[420px] w-full bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{expanded
|
||||
? createPortal(
|
||||
<div className="fixed inset-0 z-[80] flex flex-col bg-[#eef2f6]">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
className="flex h-full min-h-0 flex-col outline-none"
|
||||
>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-line bg-white px-4 py-2.5">
|
||||
<h2 className="truncate text-sm font-semibold text-ink">{documentTitle}</h2>
|
||||
{toolbar(
|
||||
'full',
|
||||
<button
|
||||
type="button"
|
||||
aria-label={closeLabel}
|
||||
title={closeLabel}
|
||||
onClick={() => setExpanded(false)}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-ink-muted transition hover:bg-surface-subtle hover:text-ink"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>,
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 p-2 sm:p-4">
|
||||
<div className="h-full overflow-hidden rounded-xl bg-white shadow-[0_1px_2px_rgba(15,23,42,0.06)] ring-1 ring-black/5">
|
||||
<ReportFrame html={html} title="html-preview-full" className="h-full w-full bg-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
{rounds.map(({ human, view }) => {
|
||||
const isActive = view?.id === activeViewId
|
||||
return (
|
||||
<section key={human.id} className="rounded-2xl border border-line bg-panel p-4">
|
||||
<div className="text-sm font-medium text-ink">شما</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-7 text-ink">{human.context}</p>
|
||||
{human.attachedFiles?.length ? (
|
||||
<AttachmentMessageCards files={human.attachedFiles} onPreview={onPreviewFile} />
|
||||
) : null}
|
||||
|
||||
{view ? (
|
||||
<div className="mt-4 border-t border-line pt-4">
|
||||
<div className="mb-2 text-sm font-medium text-accent">دستیار</div>
|
||||
{view.taskPlan?.length ? <TaskPlanCard tasks={view.taskPlan} /> : null}
|
||||
{isActive && steps.length ? (
|
||||
<StepTimeline steps={steps} activeStepId={activeStepId} onStepClick={onStepClick} />
|
||||
) : null}
|
||||
{view.thinking ? (
|
||||
<p className="text-sm text-ink-muted">در حال پردازش…</p>
|
||||
) : view.context ? (
|
||||
<MarkdownAnswer content={view.context} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, ExecutionOutput[]>
|
||||
activeStepId?: string | null
|
||||
summary?: string
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>('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 <CodeBlock key={index} code={String(output.content)} language="python" />
|
||||
}
|
||||
if (output.output_type === 'html') {
|
||||
return <HtmlPreview key={index} html={String(output.content)} />
|
||||
}
|
||||
if (output.output_type === 'markdown' || output.output_type === 'text') {
|
||||
return <MarkdownAnswer key={index} content={String(output.content)} />
|
||||
}
|
||||
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 (
|
||||
<div key={index} className="overflow-auto rounded-xl border border-line">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col} className="border-b border-line bg-surface-subtle px-2 py-1.5 text-start">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, 50).map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{columns.map((_, ci) => (
|
||||
<td key={ci} className="border-b border-line/60 px-2 py-1.5">
|
||||
{String(row[ci] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (output.output_type === 'image') {
|
||||
const src = String(output.content)
|
||||
return (
|
||||
<img key={index} src={src.startsWith('/') ? src : src} alt="" className="max-h-80 rounded-xl border border-line" />
|
||||
)
|
||||
}
|
||||
if (output.output_type === 'error') {
|
||||
return (
|
||||
<div key={index} className="rounded-xl bg-danger-soft p-3 text-sm text-danger">
|
||||
{String(output.content)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<pre key={index} className="overflow-auto rounded-xl bg-surface-subtle p-3 text-xs">
|
||||
{typeof output.content === 'string' ? output.content : JSON.stringify(output.content, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
})
|
||||
}, [stepOutputs])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-2xl border border-line bg-panel">
|
||||
<div className="flex shrink-0 gap-1 border-b border-line p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('execution')}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium ${tab === 'execution' ? 'bg-accent-soft text-accent' : 'text-ink-muted'}`}
|
||||
>
|
||||
خروجی اجرا
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('summary')}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium ${tab === 'summary' ? 'bg-accent-soft text-accent' : 'text-ink-muted'}`}
|
||||
>
|
||||
پاسخ نهایی
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{loading && tab === 'execution' && !rendered.length ? (
|
||||
<p className="text-sm text-ink-muted">در حال اجرای ابزار…</p>
|
||||
) : null}
|
||||
{tab === 'execution' ? (
|
||||
rendered.length ? <div className="space-y-3">{rendered}</div> : (
|
||||
<p className="text-sm text-ink-muted">خروجی مرحلهٔ فعال اینجا نمایش داده میشود.</p>
|
||||
)
|
||||
) : summary ? (
|
||||
<MarkdownAnswer content={summary} />
|
||||
) : (
|
||||
<p className="text-sm text-ink-muted">پس از اتمام، پاسخ نهایی اینجا ظاهر میشود.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(<MarkdownAnswer content={tableMarkdown} />)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div className="prose max-w-none text-[15px] leading-7 text-ink prose-headings:scroll-mt-4 prose-headings:text-ink prose-p:my-2.5 prose-p:leading-7 prose-li:my-1 prose-ul:my-2.5 prose-ol:my-2.5 prose-table:my-0">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
...markdownTableComponents,
|
||||
img({ src, alt, ...props }) {
|
||||
const raw = typeof src === 'string' ? src : ''
|
||||
return <img src={resolveApiUrl(raw)} alt={alt ?? ''} {...props} />
|
||||
},
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const code = String(children).replace(/\n$/, '')
|
||||
if (match) return <CodeBlock code={code} language={match[1]} />
|
||||
return (
|
||||
<code className="rounded bg-surface-subtle px-1 py-0.5 text-xs" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="fixed inset-x-4 bottom-24 z-50 mx-auto max-w-lg rounded-2xl border border-line bg-panel p-4 shadow-xl md:bottom-8">
|
||||
<p className="text-sm font-medium text-ink">سؤال از طرف دستیار</p>
|
||||
<p className="mt-2 text-sm leading-7 text-ink-muted">{question.question}</p>
|
||||
{question.options?.length ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{question.options.map((opt) => (
|
||||
<Button key={opt.id} variant="secondary" onClick={() => onReply(opt.label || opt.id)}>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button variant="secondary" onClick={() => onReject()}>
|
||||
رد
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="rounded-xl border border-line bg-[#f8f9fc] dark:bg-surface-subtle/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
className="flex w-full items-center justify-between px-3 py-2.5 text-sm font-medium text-ink"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{t('assistant.panel.executionSteps')}</span>
|
||||
<span className="rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-normal text-ink-muted">
|
||||
{doneCount}/{steps.length}
|
||||
{runningCount > 0 ? ` · ${t('assistant.execution.processing')}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{collapsed ? <ChevronDown className="h-4 w-4 text-ink-muted" /> : <ChevronUp className="h-4 w-4 text-ink-muted" />}
|
||||
</button>
|
||||
{!collapsed ? (
|
||||
<ul className="relative space-y-0.5 border-t border-line px-2 py-2">
|
||||
{steps.map((step, index) => {
|
||||
const isLast = index === steps.length - 1
|
||||
return (
|
||||
<li key={step.id} className="relative ps-1">
|
||||
{!isLast ? (
|
||||
<span className="absolute start-[1.15rem] top-9 bottom-0 w-px bg-line" aria-hidden />
|
||||
) : null}
|
||||
<ManusStepCard
|
||||
step={step}
|
||||
active={step.id === activeStepId}
|
||||
compact
|
||||
onClick={() => onStepClick?.(step.id)}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 <CheckCircle2 className="h-4 w-4 shrink-0 text-success" />
|
||||
if (status === 'in_progress') return <Loader2 className="h-4 w-4 shrink-0 animate-spin text-accent" />
|
||||
if (status === 'cancelled') return <MinusCircle className="h-4 w-4 shrink-0 text-ink-subtle" />
|
||||
return <Circle className="h-4 w-4 shrink-0 text-ink-subtle" />
|
||||
}
|
||||
|
||||
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 (
|
||||
<ol className={compact ? 'space-y-1.5' : 'space-y-1'}>
|
||||
{items.map((task, index) => {
|
||||
const active = task.status === 'in_progress'
|
||||
const done = task.status === 'completed'
|
||||
return (
|
||||
<li
|
||||
key={`${index}-${task.content}`}
|
||||
className={`flex items-start gap-2 ${
|
||||
active ? 'text-ink' : done ? 'text-ink-muted' : 'text-ink-subtle'
|
||||
}`}
|
||||
>
|
||||
<span className="mt-0.5">
|
||||
<StatusIcon status={task.status} />
|
||||
</span>
|
||||
<span className={`${compact ? 'text-[13px] leading-6' : 'text-sm'} ${done ? 'line-through decoration-line' : ''}`}>
|
||||
{task.content}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="my-3 overflow-hidden rounded-xl border border-line bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
className="flex w-full items-center justify-between px-3 py-2.5 text-sm font-medium text-ink hover:bg-surface-subtle"
|
||||
>
|
||||
<span>
|
||||
برنامه کار ({done}/{items.length})
|
||||
</span>
|
||||
<span className="text-xs text-ink-muted">{collapsed ? 'نمایش' : 'پنهان'}</span>
|
||||
</button>
|
||||
{!collapsed ? (
|
||||
<div className="border-t border-line px-3 py-2">
|
||||
<TaskPlanList tasks={items} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loaded 100 of 283 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user