Initial commit of the Kamidso web UI.

This commit is contained in:
javad committed 2026-10-10 13:34:23 +03:30
commit 13c6e7ade6
283 files changed
+35516

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
node_modules
dist
dist-ssr
.git
.github
*.local
*.log
npm-debug.log*
.env
.env.*
coverage
.vite
.cache
+8
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
# Baked into `vite build`. Origin only; the app appends /api/kamidso/v1.
VITE_API_BASE_URL=https://api.kamidso.com
+1
View File
@@ -0,0 +1 @@
nginx.prod.conf text eol=lf
+34
View File
@@ -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?
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmjs.org/
+59
View File
@@ -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
+21
View File
@@ -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.
+12
View File
@@ -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.
+35
View File
@@ -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 |
+28
View File
@@ -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
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
{
"app": "kamidso-front",
"platform": "react",
"port": 80,
"react": {
"mirror": false
}
}
+61
View File
@@ -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;
}
}
+14
View File
@@ -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;
}
}
+7114
View File
File diff suppressed because it is too large. Load diff
+56
View File
@@ -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"
}
}
+1
View File
@@ -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

+9
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
import { AppRouter } from './app/router'
export default function App() {
return <AppRouter />
}
+10
View File
@@ -0,0 +1,10 @@
export {
KAMIDSO_API,
kamidsoUrl,
ApiError,
apiFetch,
apiFetchEnvelope,
getUserId,
isDebugMode,
type KamidsoEnvelope,
} from '../services/api'
+17
View File
@@ -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)
})
})
+43
View File
@@ -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}/`))
}
+103
View File
@@ -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
}
+61
View File
@@ -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 })
})
})
+170
View File
@@ -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
}
+37
View File
@@ -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
}
+45
View File
@@ -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>
)
}
+23
View File
@@ -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>
)
}
+14
View File
@@ -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>
)
}
+10
View File
@@ -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>
)
}
+77
View File
@@ -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,
)
}
+97
View File
@@ -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>
)
}
+75
View File
@@ -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()
})
})
+296
View File
@@ -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>
)
}
+48
View File
@@ -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>
)
}
+118
View File
@@ -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>
)
}
+68
View File
@@ -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>
)
}
+127
View File
@@ -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}
</>
)
}
+97
View File
@@ -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,
})
+54
View File
@@ -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>
)
}
+91
View File
@@ -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>
)
}
+36
View File
@@ -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>
)
}
+28
View File
@@ -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>
)
}
+224
View File
@@ -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 }
+41
View File
@@ -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>
)
}
+29
View File
@@ -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>
)
}
+36
View File
@@ -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>
)
}
+354
View File
@@ -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
+48
View File
@@ -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>
)
}
+83
View File
@@ -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>
}
+62
View File
@@ -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>
)
}
+40
View File
@@ -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>
)
}
+129
View File
@@ -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,
}
)
}
+98
View File
@@ -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>
)
}
+88
View File
@@ -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}
</>
)
}
+66
View File
@@ -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])
}
+21
View File
@@ -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
}
+42
View File
@@ -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'
+25
View File
@@ -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: 'خطایی رخ داد.' }
}
+40
View File
@@ -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
}
+31
View File
@@ -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
}
+7
View File
@@ -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'
+65
View File
@@ -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
}
+245
View File
@@ -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]
}
+72
View File
@@ -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
}
+77
View File
@@ -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)}`,
)
}
+32
View File
@@ -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 []
}
}
+23
View File
@@ -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