From 13c6e7ade6a0f30b1fefc4d7c1bfda69625c90b9 Mon Sep 17 00:00:00 2001
From: javad
Date: Sat, 10 Oct 2026 13:34:23 +0330
Subject: [PATCH] Initial commit of the Kamidso web UI.
---
.dockerignore | 13 +
.env.example | 8 +
.env.production | 2 +
.gitattributes | 1 +
.gitignore | 34 +
.npmrc | 1 +
Dockerfile | 59 +
LICENSE | 21 +
NOTICE | 12 +
README.md | 35 +
eslint.config.js | 28 +
index.html | 23 +
liara.json | 8 +
nginx.conf | 61 +
nginx.prod.conf | 14 +
package-lock.json | 7114 +++++++++++++++++
package.json | 56 +
public/vite.svg | 1 +
scripts/start-frontend.ps1 | 9 +
src/App.tsx | 5 +
src/api/client.ts | 10 +
src/app/nav.test.ts | 17 +
src/app/nav.ts | 43 +
src/app/providers/FeatureFlagProvider.tsx | 103 +
src/app/router.tsx | 61 +
src/app/workspace/AssistantRuntimeContext.tsx | 21 +
.../workspace/ConversationsContext.test.tsx | 67 +
src/app/workspace/ConversationsContext.tsx | 170 +
src/app/workspace/WorkspaceContext.tsx | 37 +
src/components/Button.tsx | 45 +
src/components/Card.tsx | 23 +
src/components/ErrorMessage.tsx | 14 +
src/components/Loading.tsx | 10 +
src/components/Modal.tsx | 77 +
src/components/layout/AppShell.tsx | 97 +
src/components/layout/AppSidebar.test.tsx | 75 +
src/components/layout/AppSidebar.tsx | 296 +
src/components/layout/MobileNav.tsx | 48 +
src/components/layout/PrimarySidebar.tsx | 118 +
src/components/layout/Topbar.tsx | 68 +
src/components/ui/CardMenu.tsx | 127 +
src/components/ui/Collapsible.tsx | 97 +
src/components/ui/ConfirmDialog.tsx | 54 +
src/components/ui/Drawer.tsx | 91 +
src/components/ui/EmptyState.tsx | 36 +
src/components/ui/FeatureTabs.tsx | 28 +
src/components/ui/Field.tsx | 224 +
src/components/ui/PageHeader.tsx | 41 +
src/components/ui/PageShell.tsx | 29 +
src/components/ui/SegmentedTabs.tsx | 36 +
src/components/ui/SelectMenu.tsx | 354 +
src/components/ui/Skeleton.tsx | 48 +
src/components/ui/StatusBadge.tsx | 83 +
src/components/ui/Switch.tsx | 62 +
src/components/ui/Toast.tsx | 40 +
src/components/ui/ToastProvider.tsx | 129 +
src/components/ui/Toolbar.tsx | 98 +
src/components/ui/Tooltip.tsx | 88 +
src/components/ui/useFocusTrap.ts | 66 +
src/contracts/chat.ts | 21 +
src/contracts/common.ts | 42 +
src/contracts/errors.ts | 25 +
src/contracts/event.ts | 40 +
src/contracts/file.ts | 31 +
src/contracts/index.ts | 7 +
src/contracts/knowledge.ts | 65 +
src/contracts/report.ts | 245 +
src/contracts/run.ts | 72 +
...den-scenario-1.excel-analysis.e2e.test.tsx | 32 +
...lden-scenario-5.clarification.e2e.test.tsx | 20 +
.../adapters/historyToMessages.test.ts | 188 +
.../assistant/adapters/historyToMessages.ts | 221 +
src/features/assistant/adapters/runToTurn.ts | 30 +
.../assistant/adapters/stepLabels.test.ts | 32 +
src/features/assistant/adapters/stepLabels.ts | 71 +
src/features/assistant/api/chat.api.ts | 77 +
.../assistant/api/conversations.api.ts | 101 +
src/features/assistant/api/reports.api.ts | 32 +
src/features/assistant/api/resources.api.ts | 65 +
src/features/assistant/api/runs.api.ts | 23 +
.../assistant/artifacts/ArtifactRenderer.tsx | 26 +
.../assistant/artifacts/ChartPreview.tsx | 246 +
.../assistant/artifacts/CodePreview.tsx | 1 +
.../assistant/artifacts/FileDownloadCard.tsx | 57 +
.../assistant/artifacts/JsonPreview.tsx | 7 +
.../assistant/artifacts/TablePreview.tsx | 61 +
.../artifacts/normalizeTable.test.ts | 22 +
.../assistant/artifacts/normalizeTable.ts | 51 +
.../assistant/components/CodeBlock.tsx | 94 +
.../assistant/components/Composer.tsx | 87 +
.../assistant/components/ConversationRail.tsx | 75 +
.../assistant/components/HtmlPreview.test.tsx | 65 +
.../assistant/components/HtmlPreview.tsx | 276 +
.../assistant/components/ManusLeftPanel.tsx | 54 +
.../assistant/components/ManusRightPanel.tsx | 123 +
.../components/MarkdownAnswer.test.tsx | 23 +
.../assistant/components/MarkdownAnswer.tsx | 38 +
.../assistant/components/QuestionDialog.tsx | 34 +
.../assistant/components/StepTimeline.tsx | 62 +
.../assistant/components/TaskPlanCard.tsx | 76 +
.../assistant/components/markdownTable.tsx | 80 +
.../assistant/execution/CodeExecutionView.tsx | 168 +
.../ExecutionOutputRenderer.test.tsx | 37 +
.../execution/ExecutionOutputRenderer.tsx | 192 +
.../assistant/execution/ExecutionPanel.tsx | 215 +
.../assistant/execution/ManusStepCard.tsx | 62 +
.../assistant/execution/PanelTabBar.tsx | 50 +
.../execution/ParallelTasksPanel.tsx | 13 +
.../assistant/execution/StatusBadge.tsx | 26 +
.../assistant/execution/StepDetailCard.tsx | 55 +
.../assistant/execution/SubAgentSection.tsx | 13 +
.../assistant/execution/TaskFilesTab.tsx | 112 +
.../assistant/execution/TaskPlanCard.tsx | 1 +
.../assistant/execution/ThinkingProcess.tsx | 47 +
.../assistant/execution/TracePanel.tsx | 11 +
.../assistant/execution/groupOutputs.test.ts | 72 +
.../assistant/execution/groupOutputs.ts | 136 +
.../assistant/execution/skillOutput.ts | 11 +
.../assistant/execution/toolIcons.tsx | 37 +
src/features/assistant/files/index.ts | 21 +
.../assistant/hooks/useAssistantStream.ts | 2 +
.../assistant/input/EnhancedChatInput.tsx | 165 +
.../assistant/input/ModelSwitcher.tsx | 33 +
.../assistant/input/ResourcePicker.test.tsx | 104 +
.../assistant/input/ResourcePicker.tsx | 147 +
.../assistant/input/SkillSelector.tsx | 463 ++
.../assistant/input/ToolsBar.test.tsx | 122 +
src/features/assistant/input/ToolsBar.tsx | 102 +
src/features/assistant/input/toolbarChip.tsx | 31 +
src/features/assistant/lib/events.ts | 8 +
src/features/assistant/lib/final.test.ts | 37 +
src/features/assistant/lib/final.ts | 99 +
.../assistant/questions/QuestionDock.tsx | 83 +
.../report/downloadReportPdf.test.ts | 27 +
.../assistant/report/downloadReportPdf.ts | 85 +
.../assistant/report/extractReport.test.ts | 33 +
.../assistant/report/extractReport.ts | 65 +
.../assistant/report/formatValue.test.ts | 31 +
src/features/assistant/report/formatValue.ts | 94 +
.../session-files/AttachmentMessageCards.tsx | 61 +
.../session-files/AttachmentMessageGroup.tsx | 12 +
.../session-files/AttachmentPreview.tsx | 90 +
.../session-files/AttachmentRail.test.tsx | 26 +
.../session-files/AttachmentRail.tsx | 169 +
.../session-files/FileTitleModal.tsx | 71 +
src/features/assistant/session-files/api.ts | 151 +
.../session-files/attachment-view-model.ts | 535 ++
.../assistant/session-files/fileKind.test.ts | 36 +
.../assistant/session-files/fileKind.ts | 78 +
.../assistant/session-files/home-adapter.ts | 221 +
src/features/assistant/session-files/index.ts | 24 +
.../assistant/session-files/recovery.ts | 112 +
.../assistant/session-files/reducer.ts | 359 +
src/features/assistant/session-files/types.ts | 195 +
.../assistant/session-files/upload-queue.ts | 185 +
.../session-files/use-session-files.ts | 425 +
src/features/assistant/state/runState.ts | 55 +
.../assistant/stream/downloadFiles.test.ts | 42 +
.../assistant/stream/downloadFiles.ts | 69 +
.../assistant/stream/eventReducer.test.ts | 222 +
src/features/assistant/stream/eventReducer.ts | 385 +
.../assistant/stream/normalizeEvent.test.ts | 61 +
.../assistant/stream/normalizeEvent.ts | 89 +
src/features/assistant/stream/reconnect.ts | 5 +
src/features/assistant/stream/useRunStream.ts | 652 ++
src/features/assistant/types/chat.ts | 83 +
src/features/assistant/types/viewModels.ts | 33 +
.../workspace/ChatMessageList.test.tsx | 31 +
.../assistant/workspace/ChatMessageList.tsx | 141 +
.../assistant/workspace/ChatWelcome.tsx | 92 +
.../workspace/ConversationContextBar.test.tsx | 71 +
.../workspace/ConversationContextBar.tsx | 185 +
.../assistant/workspace/SessionTurn.test.tsx | 209 +
.../assistant/workspace/SessionTurn.tsx | 428 +
.../assistant/workspace/conversationLayout.ts | 3 +
src/features/auth/AuthContext.test.tsx | 55 +
src/features/auth/AuthContext.tsx | 181 +
src/features/auth/RequireAuth.test.tsx | 36 +
src/features/auth/RequireAuth.tsx | 27 +
src/features/auth/api/auth.api.test.ts | 76 +
src/features/auth/api/auth.api.ts | 159 +
src/features/auth/pages/LoginPage.tsx | 325 +
src/features/auth/useLogoutConfirm.test.tsx | 58 +
src/features/auth/useLogoutConfirm.tsx | 44 +
src/features/data-map/DataMapPage.tsx | 559 ++
src/features/data-map/api.test.ts | 82 +
src/features/data-map/api.ts | 197 +
.../data-map/components/DataMapCanvas.tsx | 485 ++
.../components/FieldSemanticPanel.tsx | 402 +
.../data-map/components/RelationEdge.tsx | 98 +
.../data-map/components/ResourceNode.tsx | 351 +
.../components/SemanticReviewList.tsx | 209 +
.../data-map/components/TracePanel.tsx | 331 +
.../data-map/components/relationPath.test.ts | 39 +
.../data-map/components/relationPath.ts | 48 +
src/features/data-map/types.test.ts | 161 +
src/features/data-map/types.ts | 200 +
src/features/databases/ConnectEngineModal.tsx | 235 +
src/features/databases/EngineMark.tsx | 127 +
src/features/databases/api.ts | 96 +
src/features/databases/engines.ts | 161 +
src/features/knowledge/ChunkBrowser.tsx | 87 +
src/features/knowledge/CreateSpaceModal.tsx | 517 ++
src/features/knowledge/DocumentTable.tsx | 73 +
src/features/knowledge/KnowledgeDetail.tsx | 164 +
src/features/knowledge/KnowledgeList.tsx | 150 +
src/features/knowledge/RecallTestPanel.tsx | 69 +
src/features/knowledge/api.ts | 105 +
src/features/knowledge/index.ts | 3 +
src/features/knowledge/status.test.ts | 24 +
src/features/knowledge/status.ts | 41 +
src/features/knowledge/types.ts | 13 +
src/features/landing/components/DeepDives.tsx | 130 +
.../landing/components/FaqSection.tsx | 74 +
.../landing/components/FeaturePillars.tsx | 119 +
src/features/landing/components/FinalCta.tsx | 43 +
.../landing/components/HowItWorks.tsx | 64 +
.../landing/components/LandingFooter.tsx | 88 +
.../landing/components/LandingHero.tsx | 75 +
.../landing/components/LandingNav.tsx | 107 +
.../landing/components/ProblemSolution.tsx | 51 +
src/features/landing/components/Reveal.tsx | 51 +
.../landing/components/SampleQuestions.tsx | 105 +
.../landing/components/ValueProps.tsx | 42 +
.../landing/components/WhyKamidso.tsx | 61 +
.../components/mocks/AssistantMock.tsx | 278 +
.../components/mocks/DashboardMock.tsx | 99 +
.../landing/components/mocks/RadarMock.tsx | 37 +
.../landing/components/mocks/ReportMock.tsx | 90 +
.../landing/components/mocks/WorkMock.tsx | 42 +
.../landing/components/primitives.tsx | 82 +
.../components/usePrefersReducedMotion.ts | 22 +
.../landing/pages/LandingPage.test.tsx | 81 +
src/features/landing/pages/LandingPage.tsx | 65 +
.../settings/OrganizationProfilePage.tsx | 238 +
src/features/settings/OriginBadge.tsx | 31 +
src/features/settings/SettingsFields.tsx | 199 +
src/features/settings/SettingsLayout.tsx | 42 +
src/features/settings/SkillGuide.tsx | 94 +
src/features/settings/SkillSettingsPage.tsx | 232 +
src/features/settings/api.ts | 38 +
src/features/settings/fieldHelp.test.ts | 50 +
src/features/settings/fieldHelp.ts | 69 +
src/features/settings/form.test.ts | 42 +
src/features/settings/form.ts | 69 +
src/features/settings/types.ts | 102 +
src/features/skills/SkillsCatalog.test.tsx | 111 +
src/features/skills/SkillsCatalog.tsx | 218 +
src/features/skills/catalog.test.ts | 22 +
src/features/skills/catalog.ts | 141 +
src/features/skills/icons.ts | 171 +
src/features/skills/present.ts | 94 +
src/i18n/en.ts | 390 +
src/i18n/fa.ts | 419 +
src/i18n/index.ts | 31 +
src/legacy/assistant/README.md | 22 +
src/main.tsx | 10 +
src/pages/AssistantPage.tsx | 689 ++
src/pages/DatabasesPage.tsx | 356 +
src/pages/HomePage.tsx | 91 +
src/pages/KnowledgeDetailPage.tsx | 1 +
src/pages/KnowledgePage.tsx | 1 +
src/pages/SettingsPage.tsx | 34 +
src/pages/ShareReportPage.test.tsx | 62 +
src/pages/ShareReportPage.tsx | 97 +
src/pages/SkillsPage.tsx | 17 +
src/services/api.ts | 118 +
src/services/apiBase.test.ts | 60 +
src/services/apiBase.ts | 32 +
src/styles/globals.css | 807 ++
src/test/setup.ts | 24 +
src/test/vitest.d.ts | 1 +
src/theme/rtl.css | 13 +
src/theme/tokens.css | 29 +
src/utils/formatDate.ts | 46 +
src/utils/formatMoney.ts | 12 +
src/utils/formatNumber.ts | 14 +
src/vite-env.d.ts | 17 +
tsconfig.app.json | 24 +
tsconfig.json | 7 +
tsconfig.node.json | 25 +
tsconfig.test.json | 14 +
vite.config.ts | 46 +
283 files changed, 35516 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .env.example
create mode 100644 .env.production
create mode 100644 .gitattributes
create mode 100644 .gitignore
create mode 100644 .npmrc
create mode 100644 Dockerfile
create mode 100644 LICENSE
create mode 100644 NOTICE
create mode 100644 README.md
create mode 100644 eslint.config.js
create mode 100644 index.html
create mode 100644 liara.json
create mode 100644 nginx.conf
create mode 100644 nginx.prod.conf
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 public/vite.svg
create mode 100644 scripts/start-frontend.ps1
create mode 100644 src/App.tsx
create mode 100644 src/api/client.ts
create mode 100644 src/app/nav.test.ts
create mode 100644 src/app/nav.ts
create mode 100644 src/app/providers/FeatureFlagProvider.tsx
create mode 100644 src/app/router.tsx
create mode 100644 src/app/workspace/AssistantRuntimeContext.tsx
create mode 100644 src/app/workspace/ConversationsContext.test.tsx
create mode 100644 src/app/workspace/ConversationsContext.tsx
create mode 100644 src/app/workspace/WorkspaceContext.tsx
create mode 100644 src/components/Button.tsx
create mode 100644 src/components/Card.tsx
create mode 100644 src/components/ErrorMessage.tsx
create mode 100644 src/components/Loading.tsx
create mode 100644 src/components/Modal.tsx
create mode 100644 src/components/layout/AppShell.tsx
create mode 100644 src/components/layout/AppSidebar.test.tsx
create mode 100644 src/components/layout/AppSidebar.tsx
create mode 100644 src/components/layout/MobileNav.tsx
create mode 100644 src/components/layout/PrimarySidebar.tsx
create mode 100644 src/components/layout/Topbar.tsx
create mode 100644 src/components/ui/CardMenu.tsx
create mode 100644 src/components/ui/Collapsible.tsx
create mode 100644 src/components/ui/ConfirmDialog.tsx
create mode 100644 src/components/ui/Drawer.tsx
create mode 100644 src/components/ui/EmptyState.tsx
create mode 100644 src/components/ui/FeatureTabs.tsx
create mode 100644 src/components/ui/Field.tsx
create mode 100644 src/components/ui/PageHeader.tsx
create mode 100644 src/components/ui/PageShell.tsx
create mode 100644 src/components/ui/SegmentedTabs.tsx
create mode 100644 src/components/ui/SelectMenu.tsx
create mode 100644 src/components/ui/Skeleton.tsx
create mode 100644 src/components/ui/StatusBadge.tsx
create mode 100644 src/components/ui/Switch.tsx
create mode 100644 src/components/ui/Toast.tsx
create mode 100644 src/components/ui/ToastProvider.tsx
create mode 100644 src/components/ui/Toolbar.tsx
create mode 100644 src/components/ui/Tooltip.tsx
create mode 100644 src/components/ui/useFocusTrap.ts
create mode 100644 src/contracts/chat.ts
create mode 100644 src/contracts/common.ts
create mode 100644 src/contracts/errors.ts
create mode 100644 src/contracts/event.ts
create mode 100644 src/contracts/file.ts
create mode 100644 src/contracts/index.ts
create mode 100644 src/contracts/knowledge.ts
create mode 100644 src/contracts/report.ts
create mode 100644 src/contracts/run.ts
create mode 100644 src/e2e/golden-scenario-1.excel-analysis.e2e.test.tsx
create mode 100644 src/e2e/golden-scenario-5.clarification.e2e.test.tsx
create mode 100644 src/features/assistant/adapters/historyToMessages.test.ts
create mode 100644 src/features/assistant/adapters/historyToMessages.ts
create mode 100644 src/features/assistant/adapters/runToTurn.ts
create mode 100644 src/features/assistant/adapters/stepLabels.test.ts
create mode 100644 src/features/assistant/adapters/stepLabels.ts
create mode 100644 src/features/assistant/api/chat.api.ts
create mode 100644 src/features/assistant/api/conversations.api.ts
create mode 100644 src/features/assistant/api/reports.api.ts
create mode 100644 src/features/assistant/api/resources.api.ts
create mode 100644 src/features/assistant/api/runs.api.ts
create mode 100644 src/features/assistant/artifacts/ArtifactRenderer.tsx
create mode 100644 src/features/assistant/artifacts/ChartPreview.tsx
create mode 100644 src/features/assistant/artifacts/CodePreview.tsx
create mode 100644 src/features/assistant/artifacts/FileDownloadCard.tsx
create mode 100644 src/features/assistant/artifacts/JsonPreview.tsx
create mode 100644 src/features/assistant/artifacts/TablePreview.tsx
create mode 100644 src/features/assistant/artifacts/normalizeTable.test.ts
create mode 100644 src/features/assistant/artifacts/normalizeTable.ts
create mode 100644 src/features/assistant/components/CodeBlock.tsx
create mode 100644 src/features/assistant/components/Composer.tsx
create mode 100644 src/features/assistant/components/ConversationRail.tsx
create mode 100644 src/features/assistant/components/HtmlPreview.test.tsx
create mode 100644 src/features/assistant/components/HtmlPreview.tsx
create mode 100644 src/features/assistant/components/ManusLeftPanel.tsx
create mode 100644 src/features/assistant/components/ManusRightPanel.tsx
create mode 100644 src/features/assistant/components/MarkdownAnswer.test.tsx
create mode 100644 src/features/assistant/components/MarkdownAnswer.tsx
create mode 100644 src/features/assistant/components/QuestionDialog.tsx
create mode 100644 src/features/assistant/components/StepTimeline.tsx
create mode 100644 src/features/assistant/components/TaskPlanCard.tsx
create mode 100644 src/features/assistant/components/markdownTable.tsx
create mode 100644 src/features/assistant/execution/CodeExecutionView.tsx
create mode 100644 src/features/assistant/execution/ExecutionOutputRenderer.test.tsx
create mode 100644 src/features/assistant/execution/ExecutionOutputRenderer.tsx
create mode 100644 src/features/assistant/execution/ExecutionPanel.tsx
create mode 100644 src/features/assistant/execution/ManusStepCard.tsx
create mode 100644 src/features/assistant/execution/PanelTabBar.tsx
create mode 100644 src/features/assistant/execution/ParallelTasksPanel.tsx
create mode 100644 src/features/assistant/execution/StatusBadge.tsx
create mode 100644 src/features/assistant/execution/StepDetailCard.tsx
create mode 100644 src/features/assistant/execution/SubAgentSection.tsx
create mode 100644 src/features/assistant/execution/TaskFilesTab.tsx
create mode 100644 src/features/assistant/execution/TaskPlanCard.tsx
create mode 100644 src/features/assistant/execution/ThinkingProcess.tsx
create mode 100644 src/features/assistant/execution/TracePanel.tsx
create mode 100644 src/features/assistant/execution/groupOutputs.test.ts
create mode 100644 src/features/assistant/execution/groupOutputs.ts
create mode 100644 src/features/assistant/execution/skillOutput.ts
create mode 100644 src/features/assistant/execution/toolIcons.tsx
create mode 100644 src/features/assistant/files/index.ts
create mode 100644 src/features/assistant/hooks/useAssistantStream.ts
create mode 100644 src/features/assistant/input/EnhancedChatInput.tsx
create mode 100644 src/features/assistant/input/ModelSwitcher.tsx
create mode 100644 src/features/assistant/input/ResourcePicker.test.tsx
create mode 100644 src/features/assistant/input/ResourcePicker.tsx
create mode 100644 src/features/assistant/input/SkillSelector.tsx
create mode 100644 src/features/assistant/input/ToolsBar.test.tsx
create mode 100644 src/features/assistant/input/ToolsBar.tsx
create mode 100644 src/features/assistant/input/toolbarChip.tsx
create mode 100644 src/features/assistant/lib/events.ts
create mode 100644 src/features/assistant/lib/final.test.ts
create mode 100644 src/features/assistant/lib/final.ts
create mode 100644 src/features/assistant/questions/QuestionDock.tsx
create mode 100644 src/features/assistant/report/downloadReportPdf.test.ts
create mode 100644 src/features/assistant/report/downloadReportPdf.ts
create mode 100644 src/features/assistant/report/extractReport.test.ts
create mode 100644 src/features/assistant/report/extractReport.ts
create mode 100644 src/features/assistant/report/formatValue.test.ts
create mode 100644 src/features/assistant/report/formatValue.ts
create mode 100644 src/features/assistant/session-files/AttachmentMessageCards.tsx
create mode 100644 src/features/assistant/session-files/AttachmentMessageGroup.tsx
create mode 100644 src/features/assistant/session-files/AttachmentPreview.tsx
create mode 100644 src/features/assistant/session-files/AttachmentRail.test.tsx
create mode 100644 src/features/assistant/session-files/AttachmentRail.tsx
create mode 100644 src/features/assistant/session-files/FileTitleModal.tsx
create mode 100644 src/features/assistant/session-files/api.ts
create mode 100644 src/features/assistant/session-files/attachment-view-model.ts
create mode 100644 src/features/assistant/session-files/fileKind.test.ts
create mode 100644 src/features/assistant/session-files/fileKind.ts
create mode 100644 src/features/assistant/session-files/home-adapter.ts
create mode 100644 src/features/assistant/session-files/index.ts
create mode 100644 src/features/assistant/session-files/recovery.ts
create mode 100644 src/features/assistant/session-files/reducer.ts
create mode 100644 src/features/assistant/session-files/types.ts
create mode 100644 src/features/assistant/session-files/upload-queue.ts
create mode 100644 src/features/assistant/session-files/use-session-files.ts
create mode 100644 src/features/assistant/state/runState.ts
create mode 100644 src/features/assistant/stream/downloadFiles.test.ts
create mode 100644 src/features/assistant/stream/downloadFiles.ts
create mode 100644 src/features/assistant/stream/eventReducer.test.ts
create mode 100644 src/features/assistant/stream/eventReducer.ts
create mode 100644 src/features/assistant/stream/normalizeEvent.test.ts
create mode 100644 src/features/assistant/stream/normalizeEvent.ts
create mode 100644 src/features/assistant/stream/reconnect.ts
create mode 100644 src/features/assistant/stream/useRunStream.ts
create mode 100644 src/features/assistant/types/chat.ts
create mode 100644 src/features/assistant/types/viewModels.ts
create mode 100644 src/features/assistant/workspace/ChatMessageList.test.tsx
create mode 100644 src/features/assistant/workspace/ChatMessageList.tsx
create mode 100644 src/features/assistant/workspace/ChatWelcome.tsx
create mode 100644 src/features/assistant/workspace/ConversationContextBar.test.tsx
create mode 100644 src/features/assistant/workspace/ConversationContextBar.tsx
create mode 100644 src/features/assistant/workspace/SessionTurn.test.tsx
create mode 100644 src/features/assistant/workspace/SessionTurn.tsx
create mode 100644 src/features/assistant/workspace/conversationLayout.ts
create mode 100644 src/features/auth/AuthContext.test.tsx
create mode 100644 src/features/auth/AuthContext.tsx
create mode 100644 src/features/auth/RequireAuth.test.tsx
create mode 100644 src/features/auth/RequireAuth.tsx
create mode 100644 src/features/auth/api/auth.api.test.ts
create mode 100644 src/features/auth/api/auth.api.ts
create mode 100644 src/features/auth/pages/LoginPage.tsx
create mode 100644 src/features/auth/useLogoutConfirm.test.tsx
create mode 100644 src/features/auth/useLogoutConfirm.tsx
create mode 100644 src/features/data-map/DataMapPage.tsx
create mode 100644 src/features/data-map/api.test.ts
create mode 100644 src/features/data-map/api.ts
create mode 100644 src/features/data-map/components/DataMapCanvas.tsx
create mode 100644 src/features/data-map/components/FieldSemanticPanel.tsx
create mode 100644 src/features/data-map/components/RelationEdge.tsx
create mode 100644 src/features/data-map/components/ResourceNode.tsx
create mode 100644 src/features/data-map/components/SemanticReviewList.tsx
create mode 100644 src/features/data-map/components/TracePanel.tsx
create mode 100644 src/features/data-map/components/relationPath.test.ts
create mode 100644 src/features/data-map/components/relationPath.ts
create mode 100644 src/features/data-map/types.test.ts
create mode 100644 src/features/data-map/types.ts
create mode 100644 src/features/databases/ConnectEngineModal.tsx
create mode 100644 src/features/databases/EngineMark.tsx
create mode 100644 src/features/databases/api.ts
create mode 100644 src/features/databases/engines.ts
create mode 100644 src/features/knowledge/ChunkBrowser.tsx
create mode 100644 src/features/knowledge/CreateSpaceModal.tsx
create mode 100644 src/features/knowledge/DocumentTable.tsx
create mode 100644 src/features/knowledge/KnowledgeDetail.tsx
create mode 100644 src/features/knowledge/KnowledgeList.tsx
create mode 100644 src/features/knowledge/RecallTestPanel.tsx
create mode 100644 src/features/knowledge/api.ts
create mode 100644 src/features/knowledge/index.ts
create mode 100644 src/features/knowledge/status.test.ts
create mode 100644 src/features/knowledge/status.ts
create mode 100644 src/features/knowledge/types.ts
create mode 100644 src/features/landing/components/DeepDives.tsx
create mode 100644 src/features/landing/components/FaqSection.tsx
create mode 100644 src/features/landing/components/FeaturePillars.tsx
create mode 100644 src/features/landing/components/FinalCta.tsx
create mode 100644 src/features/landing/components/HowItWorks.tsx
create mode 100644 src/features/landing/components/LandingFooter.tsx
create mode 100644 src/features/landing/components/LandingHero.tsx
create mode 100644 src/features/landing/components/LandingNav.tsx
create mode 100644 src/features/landing/components/ProblemSolution.tsx
create mode 100644 src/features/landing/components/Reveal.tsx
create mode 100644 src/features/landing/components/SampleQuestions.tsx
create mode 100644 src/features/landing/components/ValueProps.tsx
create mode 100644 src/features/landing/components/WhyKamidso.tsx
create mode 100644 src/features/landing/components/mocks/AssistantMock.tsx
create mode 100644 src/features/landing/components/mocks/DashboardMock.tsx
create mode 100644 src/features/landing/components/mocks/RadarMock.tsx
create mode 100644 src/features/landing/components/mocks/ReportMock.tsx
create mode 100644 src/features/landing/components/mocks/WorkMock.tsx
create mode 100644 src/features/landing/components/primitives.tsx
create mode 100644 src/features/landing/components/usePrefersReducedMotion.ts
create mode 100644 src/features/landing/pages/LandingPage.test.tsx
create mode 100644 src/features/landing/pages/LandingPage.tsx
create mode 100644 src/features/settings/OrganizationProfilePage.tsx
create mode 100644 src/features/settings/OriginBadge.tsx
create mode 100644 src/features/settings/SettingsFields.tsx
create mode 100644 src/features/settings/SettingsLayout.tsx
create mode 100644 src/features/settings/SkillGuide.tsx
create mode 100644 src/features/settings/SkillSettingsPage.tsx
create mode 100644 src/features/settings/api.ts
create mode 100644 src/features/settings/fieldHelp.test.ts
create mode 100644 src/features/settings/fieldHelp.ts
create mode 100644 src/features/settings/form.test.ts
create mode 100644 src/features/settings/form.ts
create mode 100644 src/features/settings/types.ts
create mode 100644 src/features/skills/SkillsCatalog.test.tsx
create mode 100644 src/features/skills/SkillsCatalog.tsx
create mode 100644 src/features/skills/catalog.test.ts
create mode 100644 src/features/skills/catalog.ts
create mode 100644 src/features/skills/icons.ts
create mode 100644 src/features/skills/present.ts
create mode 100644 src/i18n/en.ts
create mode 100644 src/i18n/fa.ts
create mode 100644 src/i18n/index.ts
create mode 100644 src/legacy/assistant/README.md
create mode 100644 src/main.tsx
create mode 100644 src/pages/AssistantPage.tsx
create mode 100644 src/pages/DatabasesPage.tsx
create mode 100644 src/pages/HomePage.tsx
create mode 100644 src/pages/KnowledgeDetailPage.tsx
create mode 100644 src/pages/KnowledgePage.tsx
create mode 100644 src/pages/SettingsPage.tsx
create mode 100644 src/pages/ShareReportPage.test.tsx
create mode 100644 src/pages/ShareReportPage.tsx
create mode 100644 src/pages/SkillsPage.tsx
create mode 100644 src/services/api.ts
create mode 100644 src/services/apiBase.test.ts
create mode 100644 src/services/apiBase.ts
create mode 100644 src/styles/globals.css
create mode 100644 src/test/setup.ts
create mode 100644 src/test/vitest.d.ts
create mode 100644 src/theme/rtl.css
create mode 100644 src/theme/tokens.css
create mode 100644 src/utils/formatDate.ts
create mode 100644 src/utils/formatMoney.ts
create mode 100644 src/utils/formatNumber.ts
create mode 100644 src/vite-env.d.ts
create mode 100644 tsconfig.app.json
create mode 100644 tsconfig.json
create mode 100644 tsconfig.node.json
create mode 100644 tsconfig.test.json
create mode 100644 vite.config.ts
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..7bf06a2
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,13 @@
+node_modules
+dist
+dist-ssr
+.git
+.github
+*.local
+*.log
+npm-debug.log*
+.env
+.env.*
+coverage
+.vite
+.cache
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..dcc84ab
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,8 @@
+# Local dev only. Leave empty so the browser calls /api/kamidso/v1 on this dev server.
+# Vite then proxies that path. Production builds read .env.production instead.
+VITE_API_BASE_URL=
+VITE_DEBUG_MODE=true
+
+# Dev server only. Not sent to the browser. Origin only, without /api.
+# start-backend.ps1 listens on 5670; this matches a local uvicorn on 8000.
+API_BASE_URL=http://127.0.0.1:8000
diff --git a/.env.production b/.env.production
new file mode 100644
index 0000000..c0b09dc
--- /dev/null
+++ b/.env.production
@@ -0,0 +1,2 @@
+# Baked into `vite build`. Origin only; the app appends /api/kamidso/v1.
+VITE_API_BASE_URL=https://api.kamidso.com
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..cafd990
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+nginx.prod.conf text eol=lf
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6d86a95
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,34 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Local env. Keep .env.example and .env.production (public build URL).
+.env
+.env.local
+.env.*.local
+
+coverage
+.vite
+.cache
+.DS_Store
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..214c29d
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+registry=https://registry.npmjs.org/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..41b59e2
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,59 @@
+# Build context is frontend/ (Coolify base directory /frontend).
+#
+# Cache contract:
+# 1. npm CLI pin (node:22-alpine ships npm 10.9.9, which crashes with "edgesOut")
+# 2. package.json + package-lock.json -> npm ci
+# 3. Source, then Vite. VITE_API_BASE_URL is applied here so it does not
+# invalidate the install layer.
+# A React/source edit must leave `npm ci` CACHED. A lockfile edit must not.
+
+ARG NODE_IMAGE=node:22-alpine
+ARG NGINX_IMAGE=nginx:1.27-alpine
+
+FROM ${NODE_IMAGE} AS build
+
+# Build-only proxy. Not written into the nginx image.
+ARG HTTP_PROXY
+ARG HTTPS_PROXY
+ARG http_proxy
+ARG https_proxy
+ARG NO_PROXY
+ARG no_proxy
+
+ARG NPM_VERSION=12.2.0
+# Empty keeps https://registry.npmjs.org. Set in Coolify for an Iran mirror.
+ARG NPM_REGISTRY=
+
+WORKDIR /app
+
+RUN --mount=type=cache,target=/root/.npm \
+ if [ -n "${NPM_REGISTRY}" ]; then npm config set registry "${NPM_REGISTRY}"; fi \
+ && npm install -g "npm@${NPM_VERSION}"
+
+COPY package.json package-lock.json ./
+
+RUN --mount=type=cache,target=/root/.npm \
+ if [ -n "${NPM_REGISTRY}" ]; then npm config set registry "${NPM_REGISTRY}"; fi \
+ && npm ci --no-audit --no-fund --allow-remote=all
+
+COPY . .
+
+# Vite inlines this at build time. A runtime env var does not change the bundle.
+# Declared after npm ci so a URL change rebuilds the bundle only.
+ARG VITE_API_BASE_URL=
+ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
+
+RUN npm run build
+
+FROM ${NGINX_IMAGE}
+
+# Local compose keeps nginx.conf (it proxies /api to the backend service).
+# Coolify passes nginx.prod.conf: the SPA is kamidso.com and the API is api.kamidso.com.
+ARG NGINX_CONF=nginx.conf
+COPY ${NGINX_CONF} /etc/nginx/conf.d/default.conf
+COPY --from=build /app/dist /usr/share/nginx/html
+
+EXPOSE 80
+
+HEALTHCHECK --interval=20s --timeout=5s --start-period=10s --retries=5 \
+ CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..4cb947d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 magic.chen
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..1897c69
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,12 @@
+Kamidso Next
+Copyright (c) 2026 Kamidso
+
+This product includes software developed as DB-GPT
+(https://github.com/eosphoros-ai/DB-GPT), licensed under the MIT License.
+
+DB-GPT
+Copyright (c) 2023 magic.chen
+
+See the LICENSE file in this repository for the full MIT license text.
+The upstream copyright and permission notice must be retained in all copies
+or substantial portions of the Software.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b98c0d2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,35 @@
+# Kamidso Frontend
+
+React + Vite SPA for the Kamidso product (Phase 1).
+
+## Stack
+
+- React 19, Vite 6, TypeScript, Tailwind v4
+- UI shell copied from kamidso-mvp; assistant chat core from DB-GPT (SSE, session files, Manus layout)
+- Backend: `/api/kamidso/v1` on Python server (default port 5670)
+
+## Run
+
+```bash
+npm install
+npm run dev
+```
+
+```powershell
+.\scripts\start-frontend.ps1
+```
+
+Open http://localhost:5173 — Vite proxies `/api` and `/images` to `http://127.0.0.1:5670`.
+
+## Routes
+
+| Route | Description |
+|---|---|
+| `/` | Landing |
+| `/login` | Login |
+| `/home` | Home + quick ask |
+| `/assistant` | DB-GPT-style agent workspace |
+| `/databases` | Datasource list |
+| `/knowledge` | Knowledge spaces |
+| `/skills` | Skills list |
+| `/settings` | Account + logout |
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..092408a
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,28 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+
+export default tseslint.config(
+ { ignores: ['dist'] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ['**/*.{ts,tsx}'],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ plugins: {
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ },
+ },
+)
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..8671cc9
--- /dev/null
+++ b/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ Kamidso
+
+
+
+
+
+
+
+
+
+
diff --git a/liara.json b/liara.json
new file mode 100644
index 0000000..386c72e
--- /dev/null
+++ b/liara.json
@@ -0,0 +1,8 @@
+{
+ "app": "kamidso-front",
+ "platform": "react",
+ "port": 80,
+ "react": {
+ "mirror": false
+ }
+}
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..ba83fc9
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,61 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ client_max_body_size 50m;
+
+ gzip on;
+ gzip_types text/css application/javascript application/json image/svg+xml;
+
+ # Docker DNS, so a recreated backend container is picked up without an nginx reload.
+ resolver 127.0.0.11 valid=30s ipv6=off;
+
+ location /api/ {
+ set $backend_upstream backend:5670;
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ }
+
+ location /files/ {
+ set $backend_upstream backend:5670;
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_buffering off;
+ proxy_read_timeout 3600s;
+ }
+
+ location /images/ {
+ set $backend_upstream backend:5670;
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_buffering off;
+ }
+
+ location /health/ {
+ set $backend_upstream backend:5670;
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ }
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/nginx.prod.conf b/nginx.prod.conf
new file mode 100644
index 0000000..8b32bb4
--- /dev/null
+++ b/nginx.prod.conf
@@ -0,0 +1,14 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+
+ gzip on;
+ gzip_types text/css application/javascript application/json image/svg+xml;
+
+ # API, files, and images are served by api.kamidso.com. This host is the SPA only,
+ # so a login cookie is never set on kamidso.com.
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..b6dda77
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,7114 @@
+{
+ "name": "kamidso-frontend",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "kamidso-frontend",
+ "version": "0.0.0",
+ "dependencies": {
+ "@xyflow/react": "^12.8.2",
+ "katex": "^0.16.21",
+ "lucide-react": "^1.31.0",
+ "qrcode": "^1.5.4",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "react-markdown": "^10.1.0",
+ "react-router-dom": "^7.18.2",
+ "react-syntax-highlighter": "^15.6.1",
+ "recharts": "^3.10.1",
+ "rehype-katex": "^7.0.1",
+ "remark-gfm": "^4.0.1",
+ "uuid": "^11.1.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.25.0",
+ "@tailwindcss/vite": "^4.3.3",
+ "@testing-library/dom": "^10.4.2",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.4",
+ "@types/qrcode": "^1.5.6",
+ "@types/react": "^19.1.2",
+ "@types/react-dom": "^19.1.2",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "@types/uuid": "^10.0.0",
+ "@vitejs/plugin-react": "^4.4.1",
+ "eslint": "^9.25.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.19",
+ "globals": "^16.0.0",
+ "jsdom": "^29.1.1",
+ "tailwindcss": "^4.3.3",
+ "typescript": "~5.8.3",
+ "typescript-eslint": "^8.30.1",
+ "vite": "^6.3.5",
+ "vitest": "^4.1.10"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.1",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.1",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.8",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.10.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.3.0",
+ "minimatch": "^3.1.5",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.12.0",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
+ "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
+ "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
+ "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
+ "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
+ "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
+ "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
+ "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
+ "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
+ "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
+ "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
+ "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
+ "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
+ "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
+ "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
+ "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
+ "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
+ "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
+ "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
+ "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
+ "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
+ "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
+ "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
+ "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.4",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.4",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+ "version": "2.8.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "7.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=22",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11",
+ "vitest": ">= 0.32"
+ },
+ "peerDependenciesMeta": {
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.12",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.5",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/katex": {
+ "version": "0.16.8",
+ "license": "MIT"
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@types/qrcode": {
+ "version": "1.5.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.18",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.4",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/react-syntax-highlighter": {
+ "version": "15.5.13",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "license": "MIT"
+ },
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "license": "MIT"
+ },
+ "node_modules/@types/uuid": {
+ "version": "10.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.66.0",
+ "@typescript-eslint/type-utils": "8.66.0",
+ "@typescript-eslint/utils": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.66.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.6",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.66.0",
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.66.0",
+ "@typescript-eslint/types": "^8.66.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.0",
+ "@typescript-eslint/utils": "8.66.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.66.0",
+ "@typescript-eslint/tsconfig-utils": "8.66.0",
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "10.2.6",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.8.5",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.66.0",
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.66.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.4.0",
+ "license": "ISC"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@xyflow/react": {
+ "version": "12.8.2",
+ "license": "MIT",
+ "dependencies": {
+ "@xyflow/system": "0.0.66",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.66",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.13",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.18",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.8",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001809",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "license": "MIT"
+ },
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "license": "MIT"
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/commander": {
+ "version": "8.3.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "license": "MIT"
+ },
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "license": "MIT"
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.403",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "license": "MIT"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-toolkit": {
+ "version": "1.51.0",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks",
+ "tests/types",
+ "tests/browser-compat"
+ ]
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.6",
+ "@eslint/js": "9.39.5",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "5.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.4.26",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=8.40"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "license": "MIT"
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fault": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "format": "^0.2.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/format": {
+ "version": "0.2.2",
+ "engines": {
+ "node": ">=0.4.x"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "16.5.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/hast-util-from-dom": {
+ "version": "5.0.1",
+ "license": "ISC",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hastscript": "^9.0.0",
+ "web-namespaces": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-dom/node_modules/hast-util-parse-selector": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-dom/node_modules/hastscript": {
+ "version": "9.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-parse-selector": "^4.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-html": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "devlop": "^1.1.0",
+ "hast-util-from-parse5": "^8.0.0",
+ "parse5": "^7.0.0",
+ "vfile": "^6.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-html-isomorphic": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-from-dom": "^5.0.0",
+ "hast-util-from-html": "^2.0.0",
+ "unist-util-remove-position": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-html/node_modules/entities": {
+ "version": "6.0.1",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/hast-util-from-html/node_modules/parse5": {
+ "version": "7.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/hast-util-from-parse5": {
+ "version": "8.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "devlop": "^1.0.0",
+ "hastscript": "^9.0.0",
+ "property-information": "^7.0.0",
+ "vfile": "^6.0.0",
+ "vfile-location": "^5.0.0",
+ "web-namespaces": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-parse5/node_modules/hast-util-parse-selector": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-parse5/node_modules/hastscript": {
+ "version": "9.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-parse-selector": "^4.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-is-element": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-parse-selector": {
+ "version": "2.2.5",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-text": {
+ "version": "4.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "hast-util-is-element": "^3.0.0",
+ "unist-util-find-after": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hastscript": {
+ "version": "6.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^2.0.0",
+ "comma-separated-tokens": "^1.0.0",
+ "hast-util-parse-selector": "^2.0.0",
+ "property-information": "^5.0.0",
+ "space-separated-tokens": "^1.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hastscript/node_modules/@types/hast": {
+ "version": "2.3.10",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2"
+ }
+ },
+ "node_modules/hastscript/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "license": "MIT"
+ },
+ "node_modules/hastscript/node_modules/comma-separated-tokens": {
+ "version": "1.0.8",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/hastscript/node_modules/property-information": {
+ "version": "5.6.0",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/hastscript/node_modules/space-separated-tokens": {
+ "version": "1.1.5",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/highlight.js": {
+ "version": "10.7.3",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/highlightjs-vue": {
+ "version": "1.0.0",
+ "license": "CC0-1.0"
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/immer": {
+ "version": "11.1.17",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "license": "MIT"
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.1",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/katex": {
+ "version": "0.16.47",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^8.3.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://package-mirror.liara.ir/repository/npm/api/npm/npm/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/lowlight": {
+ "version": "1.20.0",
+ "license": "MIT",
+ "dependencies": {
+ "fault": "^1.0.0",
+ "highlight.js": "~10.7.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "1.31.0",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.53",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "license": "MIT"
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prismjs": {
+ "version": "1.30.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "7.2.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "19.2.8",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/react-markdown": {
+ "version": "10.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
+ "node_modules/react-redux": {
+ "version": "9.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "7.18.2",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.2",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/react-syntax-highlighter": {
+ "version": "15.6.6",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "highlight.js": "^10.4.1",
+ "highlightjs-vue": "^1.0.0",
+ "lowlight": "^1.17.0",
+ "prismjs": "^1.30.0",
+ "refractor": "^3.6.0"
+ },
+ "peerDependencies": {
+ "react": ">= 0.14.0"
+ }
+ },
+ "node_modules/recharts": {
+ "version": "3.10.1",
+ "license": "MIT",
+ "workspaces": [
+ "www"
+ ],
+ "dependencies": {
+ "@reduxjs/toolkit": "^1.9.0 || 2.x.x",
+ "clsx": "^2.1.1",
+ "decimal.js-light": "^2.5.1",
+ "es-toolkit": "^1.39.3",
+ "eventemitter3": "^5.0.1",
+ "immer": "^11.1.8",
+ "react-redux": "8.x.x || 9.x.x",
+ "reselect": "5.2.0",
+ "tiny-invariant": "^1.3.3",
+ "use-sync-external-store": "^1.2.2",
+ "victory-vendor": "^37.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
+ "node_modules/refractor": {
+ "version": "3.6.0",
+ "license": "MIT",
+ "dependencies": {
+ "hastscript": "^6.0.0",
+ "parse-entities": "^2.0.0",
+ "prismjs": "~1.27.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/character-entities": {
+ "version": "1.2.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/character-entities-legacy": {
+ "version": "1.1.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/character-reference-invalid": {
+ "version": "1.1.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/is-alphabetical": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/is-alphanumerical": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^1.0.0",
+ "is-decimal": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/is-decimal": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/is-hexadecimal": {
+ "version": "1.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/parse-entities": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^1.0.0",
+ "character-entities-legacy": "^1.0.0",
+ "character-reference-invalid": "^1.0.0",
+ "is-alphanumerical": "^1.0.0",
+ "is-decimal": "^1.0.0",
+ "is-hexadecimal": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/refractor/node_modules/prismjs": {
+ "version": "1.27.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/rehype-katex": {
+ "version": "7.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/katex": "^0.16.0",
+ "hast-util-from-html-isomorphic": "^2.0.0",
+ "hast-util-to-text": "^4.0.0",
+ "katex": "^0.16.0",
+ "unist-util-visit-parents": "^6.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "license": "ISC"
+ },
+ "node_modules/reselect": {
+ "version": "5.2.0",
+ "license": "MIT"
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.62.4",
+ "@rollup/rollup-android-arm64": "4.62.4",
+ "@rollup/rollup-darwin-arm64": "4.62.4",
+ "@rollup/rollup-darwin-x64": "4.62.4",
+ "@rollup/rollup-freebsd-arm64": "4.62.4",
+ "@rollup/rollup-freebsd-x64": "4.62.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.4",
+ "@rollup/rollup-linux-arm64-musl": "4.62.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.4",
+ "@rollup/rollup-linux-loong64-musl": "4.62.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-musl": "4.62.4",
+ "@rollup/rollup-openbsd-x64": "4.62.4",
+ "@rollup/rollup-openharmony-arm64": "4.62.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.4",
+ "@rollup/rollup-win32-x64-gnu": "4.62.4",
+ "@rollup/rollup-win32-x64-msvc": "4.62.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "license": "ISC"
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "license": "MIT"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.10"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.10",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.8.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.66.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.66.0",
+ "@typescript-eslint/parser": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.0",
+ "@typescript-eslint/utils": "8.66.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.29.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-find-after": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-remove-position": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-visit": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "11.1.1",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/esm/bin/uuid"
+ }
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-location": {
+ "version": "5.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/victory-vendor": {
+ "version": "37.3.6",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/web-namespaces": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "license": "ISC"
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "license": "ISC"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/yargs/node_modules/find-up": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/locate-path": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/p-limit": {
+ "version": "2.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/yargs/node_modules/p-locate": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "4.5.7",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d4b144c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "kamidso-frontend",
+ "private": true,
+ "allowScripts": {
+ "esbuild": true
+ },
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "test": "vitest run",
+ "test:e2e": "vitest run src/e2e",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@xyflow/react": "^12.8.2",
+ "katex": "^0.16.21",
+ "lucide-react": "^1.31.0",
+ "qrcode": "^1.5.4",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "react-markdown": "^10.1.0",
+ "react-router-dom": "^7.18.2",
+ "react-syntax-highlighter": "^15.6.1",
+ "recharts": "^3.10.1",
+ "rehype-katex": "^7.0.1",
+ "remark-gfm": "^4.0.1",
+ "uuid": "^11.1.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.25.0",
+ "@tailwindcss/vite": "^4.3.3",
+ "@testing-library/dom": "^10.4.2",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.4",
+ "@types/qrcode": "^1.5.6",
+ "@types/react": "^19.1.2",
+ "@types/react-dom": "^19.1.2",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "@types/uuid": "^10.0.0",
+ "@vitejs/plugin-react": "^4.4.1",
+ "eslint": "^9.25.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.19",
+ "globals": "^16.0.0",
+ "jsdom": "^29.1.1",
+ "tailwindcss": "^4.3.3",
+ "typescript": "~5.8.3",
+ "typescript-eslint": "^8.30.1",
+ "vite": "^6.3.5",
+ "vitest": "^4.1.10"
+ }
+}
diff --git a/public/vite.svg b/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/scripts/start-frontend.ps1 b/scripts/start-frontend.ps1
new file mode 100644
index 0000000..51998b1
--- /dev/null
+++ b/scripts/start-frontend.ps1
@@ -0,0 +1,9 @@
+$ErrorActionPreference = 'Stop'
+Set-Location (Split-Path -Parent $PSScriptRoot)
+
+if (-not (Test-Path 'node_modules')) {
+ npm install
+}
+
+Write-Host "Frontend dev server: http://localhost:5173 (API proxy -> backend)"
+npm run dev
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..2f5d208
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,5 @@
+import { AppRouter } from './app/router'
+
+export default function App() {
+ return
+}
diff --git a/src/api/client.ts b/src/api/client.ts
new file mode 100644
index 0000000..db4287d
--- /dev/null
+++ b/src/api/client.ts
@@ -0,0 +1,10 @@
+export {
+ KAMIDSO_API,
+ kamidsoUrl,
+ ApiError,
+ apiFetch,
+ apiFetchEnvelope,
+ getUserId,
+ isDebugMode,
+ type KamidsoEnvelope,
+} from '../services/api'
diff --git a/src/app/nav.test.ts b/src/app/nav.test.ts
new file mode 100644
index 0000000..0d8bc57
--- /dev/null
+++ b/src/app/nav.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+import { isNavActive, pageTitleFromPath, PRIMARY_NAV } from './nav'
+
+describe('app navigation', () => {
+ it('names primary surfaces including the skills catalog', () => {
+ expect(pageTitleFromPath('/assistant')).toBe('دستیار هوشمند')
+ expect(pageTitleFromPath('/databases')).toBe('پایگاه داده')
+ expect(pageTitleFromPath('/knowledge')).toBe('دانش')
+ expect(pageTitleFromPath('/skills')).toBe('مهارتها')
+ expect(pageTitleFromPath('/settings')).toBe('تنظیمات')
+ })
+
+ it('marks skills as a primary sidebar item', () => {
+ expect(PRIMARY_NAV.some((item) => item.id === 'skills' && item.to === '/skills')).toBe(true)
+ expect(isNavActive('/skills', { to: '/skills', match: ['/skills'] })).toBe(true)
+ })
+})
diff --git a/src/app/nav.ts b/src/app/nav.ts
new file mode 100644
index 0000000..c5931f9
--- /dev/null
+++ b/src/app/nav.ts
@@ -0,0 +1,43 @@
+export const SIDEBAR_EXPANDED = 260
+export const SIDEBAR_COLLAPSED = 52
+export const SIDEBAR_COLLAPSED_STORAGE_KEY = 'kamidso.sidebarCollapsed'
+
+export type NavId = 'assistant' | 'data-map' | 'databases' | 'knowledge' | 'skills' | 'settings'
+
+export type NavItem = {
+ id: NavId
+ to: string
+ label: string
+ match: string[]
+ end?: boolean
+}
+
+export const PRIMARY_NAV: NavItem[] = [
+ { id: 'assistant', to: '/assistant', label: 'دستیار هوشمند', match: ['/assistant'] },
+ { id: 'data-map', to: '/data-map', label: 'نقشه داده', match: ['/data-map'] },
+ { id: 'databases', to: '/databases', label: 'پایگاه داده', match: ['/databases'] },
+ { id: 'knowledge', to: '/knowledge', label: 'دانش', match: ['/knowledge'] },
+ { id: 'skills', to: '/skills', label: 'مهارتها', match: ['/skills'] },
+]
+
+export const SETTINGS_NAV: NavItem = {
+ id: 'settings',
+ to: '/settings',
+ label: 'تنظیمات',
+ match: ['/settings'],
+}
+
+export function pageTitleFromPath(pathname: string): string {
+ if (pathname === '/home' || pathname.startsWith('/assistant')) return 'دستیار هوشمند'
+ if (pathname.startsWith('/data-map')) return 'نقشه داده'
+ if (pathname.startsWith('/databases')) return 'پایگاه داده'
+ if (pathname.startsWith('/knowledge')) return 'دانش'
+ if (pathname.startsWith('/skills')) return 'مهارتها'
+ if (pathname.startsWith('/settings')) return 'تنظیمات'
+ return 'Kamidso'
+}
+
+export function isNavActive(pathname: string, item: Pick) {
+ if (item.end) return pathname === item.to
+ return item.match.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))
+}
diff --git a/src/app/providers/FeatureFlagProvider.tsx b/src/app/providers/FeatureFlagProvider.tsx
new file mode 100644
index 0000000..6f4021b
--- /dev/null
+++ b/src/app/providers/FeatureFlagProvider.tsx
@@ -0,0 +1,103 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react'
+import { apiFetch } from '../../api/client'
+import { useAuth } from '../../features/auth/AuthContext'
+
+export type FeatureFlags = {
+ chat: boolean
+ files: boolean
+ skills: boolean
+ knowledge: boolean
+ datasources: boolean
+ apps: boolean
+ awel: boolean
+ reports: boolean
+ dashboards: boolean
+ widgets: boolean
+ radar: boolean
+ new_agent_workspace: boolean
+}
+
+const DEFAULT_FLAGS: FeatureFlags = {
+ chat: true,
+ files: true,
+ skills: true,
+ knowledge: true,
+ datasources: true,
+ apps: false,
+ awel: false,
+ reports: false,
+ dashboards: false,
+ widgets: false,
+ radar: false,
+ new_agent_workspace: true,
+}
+
+type FeatureFlagContextValue = {
+ flags: FeatureFlags
+ developerMode: boolean
+ loading: boolean
+ refresh: () => Promise
+ isEnabled: (key: keyof FeatureFlags) => boolean
+}
+
+const FeatureFlagContext = createContext(null)
+
+export function FeatureFlagProvider({ children }: { children: ReactNode }) {
+ const { status } = useAuth()
+ const [flags, setFlags] = useState(DEFAULT_FLAGS)
+ const [developerMode, setDeveloperMode] = useState(false)
+ const [loading, setLoading] = useState(true)
+
+ const refresh = useCallback(async () => {
+ if (status !== 'authenticated') {
+ setFlags(DEFAULT_FLAGS)
+ setDeveloperMode(false)
+ setLoading(false)
+ return
+ }
+ try {
+ const data = await apiFetch<{
+ features?: Partial
+ developer_mode?: boolean
+ }>('/features')
+ setFlags({ ...DEFAULT_FLAGS, ...(data.features ?? {}) })
+ setDeveloperMode(Boolean(data.developer_mode))
+ } catch {
+ setFlags(DEFAULT_FLAGS)
+ setDeveloperMode(false)
+ } finally {
+ setLoading(false)
+ }
+ }, [status])
+
+ useEffect(() => {
+ void refresh()
+ }, [refresh])
+
+ const value = useMemo(
+ () => ({
+ flags,
+ developerMode,
+ loading,
+ refresh,
+ isEnabled: (key: keyof FeatureFlags) => Boolean(flags[key]),
+ }),
+ [flags, developerMode, loading, refresh],
+ )
+
+ return {children}
+}
+
+export function useFeatureFlags() {
+ const ctx = useContext(FeatureFlagContext)
+ if (!ctx) throw new Error('useFeatureFlags must be used within FeatureFlagProvider')
+ return ctx
+}
diff --git a/src/app/router.tsx b/src/app/router.tsx
new file mode 100644
index 0000000..975db78
--- /dev/null
+++ b/src/app/router.tsx
@@ -0,0 +1,61 @@
+import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
+import { AppShell } from '../components/layout/AppShell'
+import { AssistantPage } from '../pages/AssistantPage'
+import { DatabasesPage } from '../pages/DatabasesPage'
+import { DataMapPage } from '../features/data-map/DataMapPage'
+import { KnowledgePage } from '../pages/KnowledgePage'
+import { KnowledgeDetailPage } from '../pages/KnowledgeDetailPage'
+import { SkillsPage } from '../pages/SkillsPage'
+import { SettingsPage } from '../pages/SettingsPage'
+import { OrganizationProfilePage } from '../features/settings/OrganizationProfilePage'
+import { SkillSettingsPage } from '../features/settings/SkillSettingsPage'
+import { LandingPage } from '../features/landing/pages/LandingPage'
+import { ShareReportPage } from '../pages/ShareReportPage'
+import { AuthProvider } from '../features/auth/AuthContext'
+import { RequireAuth } from '../features/auth/RequireAuth'
+import { LoginPage } from '../features/auth/pages/LoginPage'
+import { WorkspaceProvider } from './workspace/WorkspaceContext'
+import { FeatureFlagProvider } from './providers/FeatureFlagProvider'
+
+export function AppRouter() {
+ return (
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+ }
+ />
+
+
+
+ )
+}
diff --git a/src/app/workspace/AssistantRuntimeContext.tsx b/src/app/workspace/AssistantRuntimeContext.tsx
new file mode 100644
index 0000000..11821d7
--- /dev/null
+++ b/src/app/workspace/AssistantRuntimeContext.tsx
@@ -0,0 +1,21 @@
+import { createContext, useContext, type PropsWithChildren } from 'react'
+import { useRunStream } from '../../features/assistant/stream/useRunStream'
+
+type AssistantRuntime = ReturnType
+
+const AssistantRuntimeContext = createContext(null)
+
+export function AssistantRuntimeProvider({ children }: PropsWithChildren) {
+ const stream = useRunStream()
+ return (
+ {children}
+ )
+}
+
+export function useAssistantRuntime() {
+ const ctx = useContext(AssistantRuntimeContext)
+ if (!ctx) {
+ throw new Error('useAssistantRuntime must be used within AssistantRuntimeProvider')
+ }
+ return ctx
+}
diff --git a/src/app/workspace/ConversationsContext.test.tsx b/src/app/workspace/ConversationsContext.test.tsx
new file mode 100644
index 0000000..246fe44
--- /dev/null
+++ b/src/app/workspace/ConversationsContext.test.tsx
@@ -0,0 +1,67 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { ConversationsProvider, useConversations } from './ConversationsContext'
+import { listConversations } from '../../features/assistant/api/conversations.api'
+
+vi.mock('../../features/assistant/api/conversations.api', () => ({
+ listConversations: vi.fn(),
+}))
+
+const listMock = vi.mocked(listConversations)
+
+function Probe() {
+ const { items, hasMore, nextPage } = useConversations()
+ return (
+
+
{items.length}
+
{String(hasMore)}
+
+ {items.map((item) => (
+ - {item.title}
+ ))}
+
+
+
+ )
+}
+
+describe('ConversationsProvider', () => {
+ beforeEach(() => {
+ listMock.mockReset()
+ listMock.mockImplementation(async ({ offset = 0, limit = 20 } = {}) => {
+ const all = Array.from({ length: 45 }, (_, index) => ({
+ id: String(index + 1),
+ conversation_id: String(index + 1),
+ title: `گفتگو ${index + 1}`,
+ }))
+ return {
+ items: all.slice(offset, offset + limit),
+ total: all.length,
+ limit,
+ offset,
+ }
+ })
+ })
+
+ it('appends the next page instead of replacing the list', async () => {
+ const user = userEvent.setup()
+ render(
+
+
+ ,
+ )
+
+ await waitFor(() => expect(screen.getByTestId('count')).toHaveTextContent('20'))
+ expect(screen.getByTestId('more')).toHaveTextContent('true')
+ expect(screen.getByText('گفتگو 1')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'next' }))
+ await waitFor(() => expect(screen.getByTestId('count')).toHaveTextContent('40'))
+ expect(screen.getByText('گفتگو 1')).toBeInTheDocument()
+ expect(screen.getByText('گفتگو 21')).toBeInTheDocument()
+ expect(listMock).toHaveBeenLastCalledWith({ offset: 20, limit: 20 })
+ })
+})
diff --git a/src/app/workspace/ConversationsContext.tsx b/src/app/workspace/ConversationsContext.tsx
new file mode 100644
index 0000000..02b544e
--- /dev/null
+++ b/src/app/workspace/ConversationsContext.tsx
@@ -0,0 +1,170 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type PropsWithChildren,
+} from 'react'
+import {
+ listConversations,
+ type ConversationListItem,
+} from '../../features/assistant/api/conversations.api'
+
+export const CONVERSATION_PAGE_SIZE = 20
+
+type ConversationsContextValue = {
+ items: ConversationListItem[]
+ total: number
+ hasMore: boolean
+ hasPrev: boolean
+ loading: boolean
+ pageSize: number
+ setPageSize: (size: number) => void
+ nextPage: () => Promise
+ prevPage: () => Promise
+ refresh: () => Promise
+}
+
+const ConversationsContext = createContext(null)
+
+function pageSlice(source: ConversationListItem[], page: number, size: number) {
+ const start = page * size
+ return source.slice(start, start + size).filter(Boolean)
+}
+
+function loadedItems(source: ConversationListItem[]) {
+ return source.filter(Boolean)
+}
+
+export function ConversationsProvider({ children }: PropsWithChildren) {
+ const cache = useRef([])
+ const [items, setItems] = useState([])
+ const [total, setTotal] = useState(0)
+ const [page, setPage] = useState(0)
+ const [pageSize, setPageSizeState] = useState(CONVERSATION_PAGE_SIZE)
+ const [loading, setLoading] = useState(false)
+ const pageRef = useRef(0)
+ const pageSizeRef = useRef(CONVERSATION_PAGE_SIZE)
+ const totalRef = useRef(0)
+ const loadingRef = useRef(false)
+
+ const publish = useCallback((nextPage: number) => {
+ setItems(loadedItems(cache.current))
+ setPage(nextPage)
+ pageRef.current = nextPage
+ }, [])
+
+ const loadPage = useCallback(async (nextPage: number, size: number, force = false) => {
+ if (size <= 0 || loadingRef.current) return
+ const start = nextPage * size
+ const cached = pageSlice(cache.current, nextPage, size)
+ const knownTotal = totalRef.current
+ const complete =
+ cached.length === size || (knownTotal > 0 && start + cached.length >= knownTotal)
+
+ if (!force && complete) {
+ publish(nextPage)
+ return
+ }
+
+ loadingRef.current = true
+ setLoading(true)
+ try {
+ const result = await listConversations({ offset: start, limit: size })
+ totalRef.current = result.total
+ setTotal(result.total)
+ if (start === 0) {
+ cache.current = result.items
+ } else {
+ const next = cache.current.slice()
+ result.items.forEach((item, index) => {
+ next[start + index] = item
+ })
+ cache.current = next
+ }
+ publish(nextPage)
+ } catch {
+ if (force) {
+ cache.current = []
+ totalRef.current = 0
+ setItems([])
+ setTotal(0)
+ pageRef.current = 0
+ setPage(0)
+ }
+ } finally {
+ loadingRef.current = false
+ setLoading(false)
+ }
+ }, [publish])
+
+ useEffect(() => {
+ void loadPage(0, pageSizeRef.current, true)
+ }, [loadPage])
+
+ const setPageSize = useCallback(
+ (size: number) => {
+ const next = Math.max(1, Math.floor(size))
+ if (next === pageSizeRef.current) return
+ pageSizeRef.current = next
+ setPageSizeState(next)
+ pageRef.current = 0
+ void loadPage(0, next, true)
+ },
+ [loadPage],
+ )
+
+ const nextPage = useCallback(async () => {
+ const size = pageSizeRef.current
+ if (size <= 0 || loadingRef.current) return false
+ const loaded = loadedItems(cache.current).length
+ if (totalRef.current > 0 && loaded >= totalRef.current) return false
+ const next = pageRef.current + 1
+ const before = loaded
+ await loadPage(next, size)
+ return loadedItems(cache.current).length > before
+ }, [loadPage])
+
+ const prevPage = useCallback(async () => {
+ const size = pageSizeRef.current
+ if (size <= 0 || pageRef.current <= 0) return
+ await loadPage(pageRef.current - 1, size)
+ }, [loadPage])
+
+ const refresh = useCallback(async () => {
+ cache.current = []
+ pageRef.current = 0
+ const size = pageSizeRef.current
+ if (size > 0) await loadPage(0, size, true)
+ }, [loadPage])
+
+ const hasMore = pageSize > 0 && items.length < total
+ const hasPrev = page > 0
+
+ const value = useMemo(
+ () => ({
+ items,
+ total,
+ hasMore,
+ hasPrev,
+ loading,
+ pageSize,
+ setPageSize,
+ nextPage,
+ prevPage,
+ refresh,
+ }),
+ [items, total, hasMore, hasPrev, loading, pageSize, setPageSize, nextPage, prevPage, refresh],
+ )
+
+ return {children}
+}
+
+export function useConversations() {
+ const ctx = useContext(ConversationsContext)
+ if (!ctx) throw new Error('useConversations must be used within ConversationsProvider')
+ return ctx
+}
diff --git a/src/app/workspace/WorkspaceContext.tsx b/src/app/workspace/WorkspaceContext.tsx
new file mode 100644
index 0000000..bcfbd36
--- /dev/null
+++ b/src/app/workspace/WorkspaceContext.tsx
@@ -0,0 +1,37 @@
+import { createContext, useContext, type PropsWithChildren } from 'react'
+import { useAuth } from '../../features/auth/AuthContext'
+
+export type WorkspaceContextValue = {
+ organization: { id: string; name: string }
+ activeWorkspace: { id: string; name: string; organization_id?: string } | null
+ loading: boolean
+}
+
+const WorkspaceContext = createContext(null)
+
+export function WorkspaceProvider({ children }: PropsWithChildren) {
+ const { user, activeWorkspace } = useAuth()
+ const value: WorkspaceContextValue = {
+ organization: {
+ id: activeWorkspace?.organization_id ?? user?.workspace_id ?? user?.user_id ?? '',
+ name: activeWorkspace?.name ?? user?.name ?? 'کامیدسو',
+ },
+ activeWorkspace: activeWorkspace
+ ? {
+ id: activeWorkspace.id,
+ name: activeWorkspace.name,
+ organization_id: activeWorkspace.organization_id,
+ }
+ : user?.workspace_id
+ ? { id: user.workspace_id, name: user.name ?? 'فضای کاری' }
+ : null,
+ loading: false,
+ }
+ return {children}
+}
+
+export function useWorkspace() {
+ const ctx = useContext(WorkspaceContext)
+ if (!ctx) throw new Error('useWorkspace must be used within WorkspaceProvider')
+ return ctx
+}
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
new file mode 100644
index 0000000..4d4b089
--- /dev/null
+++ b/src/components/Button.tsx
@@ -0,0 +1,45 @@
+import type { ButtonHTMLAttributes, PropsWithChildren } from 'react'
+
+type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
+type Size = 'sm' | 'md' | 'lg'
+
+const styles: Record = {
+ primary:
+ 'bg-accent text-white shadow-[0_8px_18px_-12px_rgba(37,99,235,0.8)] hover:bg-accent-hover disabled:bg-accent/40 disabled:shadow-none',
+ secondary:
+ 'bg-white text-ink border border-line hover:border-line-strong hover:bg-surface-subtle disabled:opacity-50',
+ ghost: 'bg-transparent text-ink-muted hover:bg-surface-subtle hover:text-ink disabled:opacity-50',
+ danger: 'bg-danger text-white hover:bg-red-600 disabled:opacity-50',
+}
+
+const sizes: Record = {
+ sm: 'h-8 gap-1.5 px-3 text-xs',
+ md: 'h-9 gap-2 px-4 text-sm',
+ lg: 'h-11 gap-2 px-5 text-sm',
+}
+
+type Props = PropsWithChildren<
+ ButtonHTMLAttributes & {
+ variant?: Variant
+ size?: Size
+ }
+>
+
+export function Button({
+ children,
+ className = '',
+ variant = 'primary',
+ size = 'md',
+ type = 'button',
+ ...rest
+}: Props) {
+ return (
+
+ )
+}
diff --git a/src/components/Card.tsx b/src/components/Card.tsx
new file mode 100644
index 0000000..1aef2b4
--- /dev/null
+++ b/src/components/Card.tsx
@@ -0,0 +1,23 @@
+import type { PropsWithChildren, ReactNode } from 'react'
+
+type Props = PropsWithChildren<{
+ className?: string
+ title?: string
+ action?: ReactNode
+}>
+
+export function Card({ children, className = '', title, action }: Props) {
+ return (
+
+ {(title || action) && (
+
+ {title ?
{title}
: }
+ {action}
+
+ )}
+ {children}
+
+ )
+}
diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx
new file mode 100644
index 0000000..ac7c06b
--- /dev/null
+++ b/src/components/ErrorMessage.tsx
@@ -0,0 +1,14 @@
+import { AlertCircle } from 'lucide-react'
+
+export function ErrorMessage({ message }: { message: string }) {
+ if (!message) return null
+ return (
+
+ )
+}
diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx
new file mode 100644
index 0000000..9e38453
--- /dev/null
+++ b/src/components/Loading.tsx
@@ -0,0 +1,10 @@
+import { LoaderCircle } from 'lucide-react'
+
+export function Loading({ label = 'در حال بارگذاری...' }: { label?: string }) {
+ return (
+
+
+ {label}
+
+ )
+}
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
new file mode 100644
index 0000000..532a1a8
--- /dev/null
+++ b/src/components/Modal.tsx
@@ -0,0 +1,77 @@
+import { useEffect, type PropsWithChildren, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import { X } from 'lucide-react'
+import { useBodyScrollLock } from './ui/useFocusTrap'
+
+type Size = 'sm' | 'md' | 'lg' | 'xl'
+
+const WIDTHS: Record = {
+ sm: 'max-w-md',
+ md: 'max-w-lg',
+ lg: 'max-w-2xl',
+ xl: 'max-w-4xl',
+}
+
+type Props = PropsWithChildren<{
+ open: boolean
+ title: string
+ description?: string
+ size?: Size
+ footer?: ReactNode
+ onClose: () => void
+}>
+
+export function Modal({ open, title, description, size = 'md', footer, onClose, children }: Props) {
+ useBodyScrollLock(open)
+ useEffect(() => {
+ if (!open) return
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') onClose()
+ }
+ document.addEventListener('keydown', onKeyDown)
+ return () => document.removeEventListener('keydown', onKeyDown)
+ }, [open, onClose])
+
+ if (!open) return null
+
+ return createPortal(
+
+
+
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+
+
+
{children}
+ {footer ? (
+
+ {footer}
+
+ ) : null}
+
+
,
+ document.body,
+ )
+}
diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx
new file mode 100644
index 0000000..5a33043
--- /dev/null
+++ b/src/components/layout/AppShell.tsx
@@ -0,0 +1,97 @@
+import { useCallback, useEffect, useState, type PropsWithChildren } from 'react'
+import { useLocation } from 'react-router-dom'
+import { Menu } from 'lucide-react'
+import { SIDEBAR_COLLAPSED_STORAGE_KEY, pageTitleFromPath } from '../../app/nav'
+import { ConversationsProvider } from '../../app/workspace/ConversationsContext'
+import { AssistantRuntimeProvider } from '../../app/workspace/AssistantRuntimeContext'
+import { AppSidebar } from './AppSidebar'
+
+function storedPreference() {
+ try {
+ return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === '1'
+ } catch {
+ return false
+ }
+}
+
+export function AppShell({ children }: PropsWithChildren) {
+ const { pathname, search } = useLocation()
+ const fullscreen =
+ pathname.startsWith('/dashboards/') &&
+ new URLSearchParams(search).get('fullscreen') === '1'
+ const lockPageScroll = pathname.startsWith('/assistant')
+ const [collapsed, setCollapsed] = useState(storedPreference)
+ const [mobileOpen, setMobileOpen] = useState(false)
+
+ useEffect(() => {
+ const onResize = () => {
+ if (window.innerWidth < 1024) setCollapsed(true)
+ else setCollapsed(storedPreference())
+ if (window.innerWidth >= 768) setMobileOpen(false)
+ }
+ onResize()
+ window.addEventListener('resize', onResize)
+ return () => window.removeEventListener('resize', onResize)
+ }, [])
+
+ const toggleCollapsed = useCallback(() => {
+ setCollapsed((value) => {
+ const next = !value
+ try {
+ localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, next ? '1' : '0')
+ } catch {
+ // preference persistence is best-effort
+ }
+ return next
+ })
+ }, [])
+
+ if (fullscreen) {
+ return {children}
+ }
+
+ return (
+
+
+
+
+
+ {mobileOpen ? (
+
+
+ ) : null}
+
+
+
+
+ {pageTitleFromPath(pathname)}
+
+
+ {children}
+
+
+
+
+
+ )
+}
diff --git a/src/components/layout/AppSidebar.test.tsx b/src/components/layout/AppSidebar.test.tsx
new file mode 100644
index 0000000..baf8372
--- /dev/null
+++ b/src/components/layout/AppSidebar.test.tsx
@@ -0,0 +1,75 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+import { AppSidebar } from './AppSidebar'
+
+const logout = vi.fn(async () => {})
+
+vi.mock('../../features/auth/AuthContext', () => ({
+ useAuth: () => ({
+ user: { user_id: '1', email: 'admin@kamidso.local' },
+ logout,
+ }),
+}))
+
+const nextPage = vi.fn(async () => false)
+
+vi.mock('../../app/workspace/ConversationsContext', () => ({
+ useConversations: () => ({
+ items: [
+ { id: 'c1', conversation_id: 'c1', title: 'می خوام بهترین فروشند ام رو بهم بگی کی هست ؟' },
+ ],
+ hasMore: true,
+ hasPrev: false,
+ loading: false,
+ setPageSize: () => {},
+ nextPage,
+ prevPage: async () => {},
+ }),
+}))
+
+describe('AppSidebar account footer', () => {
+ it('opens the account page from the profile and keeps logout behind a confirm', async () => {
+ const user = userEvent.setup()
+ logout.mockClear()
+ render(
+
+
+ ,
+ )
+
+ const profile = screen.getByRole('link', { name: 'حساب کاربری' })
+ expect(profile).toHaveAttribute('href', '/settings')
+ expect(screen.getByText('admin@kamidso.local')).toBeInTheDocument()
+
+ await user.click(profile)
+ expect(logout).not.toHaveBeenCalled()
+ expect(screen.queryByText('مطمئن هستید که میخواهید از حساب کاربری خارج شوید؟')).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'خروج' }))
+ expect(screen.getByText('مطمئن هستید که میخواهید از حساب کاربری خارج شوید؟')).toBeInTheDocument()
+ expect(logout).not.toHaveBeenCalled()
+ })
+
+ it('scrolls conversations and loads the next page at the end', async () => {
+ nextPage.mockClear()
+ render(
+
+
+ ,
+ )
+
+ expect(screen.queryByRole('button', { name: 'مشاهده بیشتر' })).not.toBeInTheDocument()
+ const list = screen.getByTestId('conversation-list')
+ expect(list).toHaveClass('sidebar-scroll')
+ expect(screen.getByText('می خوام بهترین فروشند ام رو بهم بگی کی هست ؟')).toBeInTheDocument()
+
+ Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 800 })
+ Object.defineProperty(list, 'clientHeight', { configurable: true, value: 170 })
+ Object.defineProperty(list, 'scrollTop', { configurable: true, value: 740 })
+ fireEvent.scroll(list)
+
+ expect(nextPage).toHaveBeenCalled()
+ })
+})
diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx
new file mode 100644
index 0000000..d4cb6a3
--- /dev/null
+++ b/src/components/layout/AppSidebar.tsx
@@ -0,0 +1,296 @@
+import { NavLink, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
+import { useCallback, useEffect, useRef } from 'react'
+import {
+ BookOpen,
+ Database,
+ Network,
+ Loader2,
+ LogOut,
+ MessageSquarePlus,
+ PanelRightClose,
+ Settings,
+ Sparkles,
+ WandSparkles,
+} from 'lucide-react'
+import { isNavActive, PRIMARY_NAV, SETTINGS_NAV, SIDEBAR_COLLAPSED, SIDEBAR_EXPANDED } from '../../app/nav'
+import { useConversations } from '../../app/workspace/ConversationsContext'
+import { useAuth } from '../../features/auth/AuthContext'
+import { useLogoutConfirm } from '../../features/auth/useLogoutConfirm'
+
+const ICONS = {
+ assistant: Sparkles,
+ 'data-map': Network,
+ databases: Database,
+ knowledge: BookOpen,
+ skills: WandSparkles,
+} as const
+
+function navClass(active: boolean, compact: boolean) {
+ return `group relative flex items-center gap-2.5 rounded-xl text-sm transition ${
+ compact ? 'mx-auto h-10 w-10 justify-center' : 'h-10 px-3'
+ } ${
+ active
+ ? 'bg-accent-soft font-medium text-accent'
+ : 'text-ink-muted hover:bg-surface-subtle hover:text-ink'
+ }`
+}
+
+export function AppSidebar({
+ collapsed,
+ onToggleCollapsed,
+ overlay = false,
+ onNavigate,
+}: {
+ collapsed: boolean
+ onToggleCollapsed?: () => void
+ overlay?: boolean
+ onNavigate?: () => void
+}) {
+ const { pathname } = useLocation()
+ const [searchParams] = useSearchParams()
+ const navigate = useNavigate()
+ const { user } = useAuth()
+ const { ask: askLogout, dialog: logoutDialog } = useLogoutConfirm()
+ const { items, hasMore, loading, nextPage } = useConversations()
+ const activeConvId = searchParams.get('id')
+ const width = overlay || !collapsed ? SIDEBAR_EXPANDED : SIDEBAR_COLLAPSED
+ const compact = collapsed && !overlay
+ const listRef = useRef(null)
+ const loadingMoreRef = useRef(false)
+ const filledForLengthRef = useRef(-1)
+ const hasMoreRef = useRef(hasMore)
+ const loadingRef = useRef(loading)
+ hasMoreRef.current = hasMore
+ loadingRef.current = loading
+
+ const loadMoreIfNeeded = useCallback(
+ async (el: HTMLElement) => {
+ if (!hasMoreRef.current || loadingRef.current || loadingMoreRef.current) return
+ const remaining = el.scrollHeight - el.scrollTop - el.clientHeight
+ const noOverflow = el.scrollHeight <= el.clientHeight + 4
+ if (!noOverflow && remaining > 64) return
+ loadingMoreRef.current = true
+ try {
+ await nextPage()
+ } finally {
+ loadingMoreRef.current = false
+ }
+ },
+ [nextPage],
+ )
+
+ useEffect(() => {
+ const el = listRef.current
+ if (compact || !el || loading || !hasMore) return
+ if (filledForLengthRef.current === items.length) return
+ if (el.scrollHeight <= el.clientHeight + 4) {
+ filledForLengthRef.current = items.length
+ void loadMoreIfNeeded(el)
+ }
+ }, [compact, items.length, hasMore, loading, loadMoreIfNeeded])
+
+ function goNewChat() {
+ onNavigate?.()
+ navigate('/assistant', { state: { newChat: Date.now() } })
+ }
+
+ function goConversation(id: string) {
+ onNavigate?.()
+ navigate(`/assistant?id=${encodeURIComponent(id)}`)
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx
new file mode 100644
index 0000000..414116d
--- /dev/null
+++ b/src/components/layout/MobileNav.tsx
@@ -0,0 +1,48 @@
+import { BookOpen, Database, House, Network, Settings, Sparkles, WandSparkles } from 'lucide-react'
+import { NavLink, useLocation } from 'react-router-dom'
+import { isNavActive, PRIMARY_NAV } from '../../app/nav'
+
+const ICONS = {
+ home: House,
+ assistant: Sparkles,
+ 'data-map': Network,
+ databases: Database,
+ knowledge: BookOpen,
+ skills: WandSparkles,
+ settings: Settings,
+} as const
+
+const MOBILE_IDS = ['assistant', 'data-map', 'databases', 'knowledge', 'skills'] as const
+
+export function MobileNav() {
+ const { pathname } = useLocation()
+ const items = PRIMARY_NAV.filter((item) => MOBILE_IDS.includes(item.id as (typeof MOBILE_IDS)[number]))
+
+ return (
+
+ )
+}
diff --git a/src/components/layout/PrimarySidebar.tsx b/src/components/layout/PrimarySidebar.tsx
new file mode 100644
index 0000000..dff5db0
--- /dev/null
+++ b/src/components/layout/PrimarySidebar.tsx
@@ -0,0 +1,118 @@
+import { NavLink, useLocation } from 'react-router-dom'
+import {
+ BookOpen,
+ ChevronRight,
+ Database,
+ Network,
+ House,
+ PanelRightClose,
+ Settings,
+ Sparkles,
+ WandSparkles,
+} from 'lucide-react'
+import { isNavActive, PRIMARY_NAV, SIDEBAR_COLLAPSED, SIDEBAR_EXPANDED } from '../../app/nav'
+
+const ICONS = {
+ home: House,
+ assistant: Sparkles,
+ 'data-map': Network,
+ databases: Database,
+ knowledge: BookOpen,
+ skills: WandSparkles,
+ settings: Settings,
+} as const
+
+export function PrimarySidebar({
+ collapsed,
+ onNavigate,
+ onToggleCollapsed,
+ overlay = false,
+}: {
+ collapsed: boolean
+ onNavigate?: () => void
+ onToggleCollapsed?: () => void
+ overlay?: boolean
+}) {
+ const { pathname } = useLocation()
+ const width = collapsed ? SIDEBAR_COLLAPSED : SIDEBAR_EXPANDED
+
+ return (
+
+ )
+}
diff --git a/src/components/layout/Topbar.tsx b/src/components/layout/Topbar.tsx
new file mode 100644
index 0000000..e6f44c6
--- /dev/null
+++ b/src/components/layout/Topbar.tsx
@@ -0,0 +1,68 @@
+import { LogOut, Menu } from 'lucide-react'
+import { Link, useLocation } from 'react-router-dom'
+import { pageTitleFromPath } from '../../app/nav'
+import { useWorkspace } from '../../app/workspace/WorkspaceContext'
+import { useAuth } from '../../features/auth/AuthContext'
+import { useLogoutConfirm } from '../../features/auth/useLogoutConfirm'
+
+export function Topbar({ onMenu }: { onMenu: () => void }) {
+ const { pathname } = useLocation()
+ const { user } = useAuth()
+ const { organization } = useWorkspace()
+ const { ask, dialog } = useLogoutConfirm()
+ const title = pageTitleFromPath(pathname)
+ const hideTitle = pathname.startsWith('/assistant')
+ const orgLabel = organization.name && organization.name !== 'فضای کاری' ? organization.name : null
+
+ return (
+
+
+
+ {hideTitle ? null : (
+
{title}
+ )}
+
+
+
+ {orgLabel ? (
+
+ {orgLabel}
+
+ ) : null}
+
+ {user?.email}
+
+
+ {(user?.email || 'K').slice(0, 1).toUpperCase()}
+
+
+
+ {dialog}
+
+ )
+}
diff --git a/src/components/ui/CardMenu.tsx b/src/components/ui/CardMenu.tsx
new file mode 100644
index 0000000..c486dd1
--- /dev/null
+++ b/src/components/ui/CardMenu.tsx
@@ -0,0 +1,127 @@
+import { MoreHorizontal } from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+import { useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { selectMenuPanelClass } from './SelectMenu'
+
+export type CardMenuItem = {
+ label: string
+ icon?: LucideIcon
+ onSelect: () => void
+ tone?: 'default' | 'danger'
+ disabled?: boolean
+}
+
+type MenuPos = { top: number; left: number; openUp: boolean }
+
+export function CardMenu({ items, label = 'عملیات بیشتر' }: { items: CardMenuItem[]; label?: string }) {
+ const [open, setOpen] = useState(false)
+ const [pos, setPos] = useState(null)
+ const triggerRef = useRef(null)
+ const menuRef = useRef(null)
+
+ useLayoutEffect(() => {
+ if (!open || !triggerRef.current) return
+ const rect = triggerRef.current.getBoundingClientRect()
+ const menuWidth = 188
+ const menuHeight = Math.min(240, items.length * 40 + 16)
+ const spaceBelow = window.innerHeight - rect.bottom
+ const openUp = spaceBelow < menuHeight && rect.top > menuHeight
+ setPos({
+ top: openUp ? rect.top - 6 : rect.bottom + 6,
+ left: Math.min(Math.max(8, rect.right - menuWidth), window.innerWidth - menuWidth - 8),
+ openUp,
+ })
+ }, [open, items.length])
+
+ useEffect(() => {
+ if (!open) return
+ function onPointerDown(event: MouseEvent) {
+ const target = event.target as Node
+ if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) {
+ setOpen(false)
+ }
+ }
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') setOpen(false)
+ }
+ document.addEventListener('mousedown', onPointerDown)
+ document.addEventListener('keydown', onKeyDown)
+ return () => {
+ document.removeEventListener('mousedown', onPointerDown)
+ document.removeEventListener('keydown', onKeyDown)
+ }
+ }, [open])
+
+ useEffect(() => {
+ if (!open) return
+ function onScroll() {
+ setOpen(false)
+ }
+ window.addEventListener('scroll', onScroll, true)
+ return () => window.removeEventListener('scroll', onScroll, true)
+ }, [open])
+
+ if (items.length === 0) return null
+
+ const menu =
+ open && pos
+ ? createPortal(
+
+ {items.map((item) => {
+ const Icon = item.icon
+ return (
+
+ )
+ })}
+
,
+ document.body,
+ )
+ : null
+
+ return (
+ <>
+
+ {menu}
+ >
+ )
+}
diff --git a/src/components/ui/Collapsible.tsx b/src/components/ui/Collapsible.tsx
new file mode 100644
index 0000000..a008265
--- /dev/null
+++ b/src/components/ui/Collapsible.tsx
@@ -0,0 +1,97 @@
+import { ChevronDown } from 'lucide-react'
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useState,
+ type PropsWithChildren,
+} from 'react'
+
+type CollapsibleContextValue = {
+ open: boolean
+ toggle: () => void
+ onOpenChange?: (open: boolean) => void
+}
+
+const CollapsibleContext = createContext(null)
+
+function useCollapsible() {
+ const ctx = useContext(CollapsibleContext)
+ if (!ctx) throw new Error('Collapsible components must be used within Collapsible')
+ return ctx
+}
+
+function CollapsibleRoot({
+ children,
+ open: controlledOpen,
+ defaultOpen = false,
+ onOpenChange,
+ className = '',
+}: PropsWithChildren<{
+ open?: boolean
+ defaultOpen?: boolean
+ onOpenChange?: (open: boolean) => void
+ className?: string
+}>) {
+ const [internalOpen, setInternalOpen] = useState(defaultOpen)
+ const isControlled = controlledOpen !== undefined
+ const open = isControlled ? controlledOpen : internalOpen
+
+ const toggle = useCallback(() => {
+ const next = !open
+ if (!isControlled) setInternalOpen(next)
+ onOpenChange?.(next)
+ }, [open, isControlled, onOpenChange])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function CollapsibleTrigger({
+ children,
+ className = '',
+}: PropsWithChildren<{ className?: string }>) {
+ const { toggle, open } = useCollapsible()
+ return (
+
+ )
+}
+
+function CollapsibleContent({
+ children,
+ className = '',
+}: PropsWithChildren<{ className?: string }>) {
+ const { open } = useCollapsible()
+ if (!open) return null
+ return {children}
+}
+
+function CollapsibleArrow({ className = '' }: { className?: string }) {
+ const { open } = useCollapsible()
+ return (
+
+ )
+}
+
+export const Collapsible = Object.assign(CollapsibleRoot, {
+ Trigger: CollapsibleTrigger,
+ Content: CollapsibleContent,
+ Arrow: CollapsibleArrow,
+})
diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx
new file mode 100644
index 0000000..94b083b
--- /dev/null
+++ b/src/components/ui/ConfirmDialog.tsx
@@ -0,0 +1,54 @@
+import { AlertTriangle, Info, Loader2 } from 'lucide-react'
+import { Modal } from '../Modal'
+import { Button } from '../Button'
+
+type Props = {
+ open: boolean
+ title: string
+ message: string
+ confirmLabel?: string
+ cancelLabel?: string
+ tone?: 'danger' | 'default'
+ busy?: boolean
+ onConfirm: () => void
+ onCancel: () => void
+}
+
+export function ConfirmDialog({
+ open,
+ title,
+ message,
+ confirmLabel = 'تأیید',
+ cancelLabel = 'انصراف',
+ tone = 'default',
+ busy = false,
+ onConfirm,
+ onCancel,
+}: Props) {
+ const danger = tone === 'danger'
+ const Icon = danger ? AlertTriangle : Info
+
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/ui/Drawer.tsx b/src/components/ui/Drawer.tsx
new file mode 100644
index 0000000..bf80b5b
--- /dev/null
+++ b/src/components/ui/Drawer.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useRef, type PropsWithChildren, type ReactNode } from 'react'
+import { X } from 'lucide-react'
+import { useBodyScrollLock, useFocusTrap } from './useFocusTrap'
+
+type Props = PropsWithChildren<{
+ open: boolean
+ title: string
+ description?: string
+ /** Logical edge: `end` is the left side in RTL. Defaults to `end`. */
+ side?: 'start' | 'end'
+ width?: string
+ footer?: ReactNode
+ headerExtra?: ReactNode
+ onClose: () => void
+}>
+
+export function Drawer({
+ open,
+ title,
+ description,
+ side = 'end',
+ width = 'w-full max-w-md',
+ footer,
+ headerExtra,
+ onClose,
+ children,
+}: Props) {
+ const panelRef = useRef(null)
+ useFocusTrap(panelRef, open)
+ useBodyScrollLock(open)
+
+ useEffect(() => {
+ if (!open) return
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') onClose()
+ }
+ document.addEventListener('keydown', onKeyDown)
+ return () => document.removeEventListener('keydown', onKeyDown)
+ }, [open, onClose])
+
+ if (!open) return null
+
+ return (
+
+
+
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+
+ {headerExtra}
+
+
+
+
+
{children}
+
+ {footer ? (
+
+ {footer}
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx
new file mode 100644
index 0000000..58755ea
--- /dev/null
+++ b/src/components/ui/EmptyState.tsx
@@ -0,0 +1,36 @@
+import type { LucideIcon } from 'lucide-react'
+import type { ReactNode } from 'react'
+import { Link } from 'react-router-dom'
+import { Button } from '../Button'
+
+export function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ actionTo,
+ actionLabel,
+ children,
+}: {
+ icon: LucideIcon
+ title: string
+ description: string
+ actionTo?: string | null
+ actionLabel?: string
+ children?: ReactNode
+}) {
+ return (
+
+
+
+
+
{title}
+
{description}
+ {actionTo && actionLabel ? (
+
+
+
+ ) : null}
+ {children ?
{children}
: null}
+
+ )
+}
diff --git a/src/components/ui/FeatureTabs.tsx b/src/components/ui/FeatureTabs.tsx
new file mode 100644
index 0000000..4c48e92
--- /dev/null
+++ b/src/components/ui/FeatureTabs.tsx
@@ -0,0 +1,28 @@
+import { NavLink } from 'react-router-dom'
+
+export function FeatureTabs({
+ items,
+}: {
+ items: Array<{ to: string; label: string; end?: boolean }>
+}) {
+ return (
+
+ {items.map((item) => (
+
+ `rounded-xl px-3.5 py-2 text-sm transition ${
+ isActive
+ ? 'bg-white font-medium text-ink shadow-[0_1px_2px_rgba(23,32,51,0.06)]'
+ : 'text-ink-muted hover:text-ink'
+ }`
+ }
+ >
+ {item.label}
+
+ ))}
+
+ )
+}
diff --git a/src/components/ui/Field.tsx b/src/components/ui/Field.tsx
new file mode 100644
index 0000000..18daaf8
--- /dev/null
+++ b/src/components/ui/Field.tsx
@@ -0,0 +1,224 @@
+import { AlertCircle } from 'lucide-react'
+import {
+ forwardRef,
+ useId,
+ type InputHTMLAttributes,
+ type ReactNode,
+ type TextareaHTMLAttributes,
+} from 'react'
+import { SelectMenu, type SelectMenuOption } from './SelectMenu'
+
+const CONTROL_BASE =
+ 'w-full rounded-xl border bg-white text-sm text-ink outline-none transition placeholder:text-ink-subtle disabled:cursor-not-allowed disabled:bg-surface-subtle disabled:text-ink-muted'
+
+function controlClass(invalid?: boolean, extra = '') {
+ return `${CONTROL_BASE} ${
+ invalid
+ ? 'border-danger/50 focus:border-danger focus:ring-2 focus:ring-danger/15'
+ : 'border-line hover:border-line-strong focus:border-accent focus:ring-2 focus:ring-accent/15'
+ } ${extra}`
+}
+
+type FieldProps = {
+ label?: ReactNode
+ hint?: ReactNode
+ error?: string | null
+ required?: boolean
+ htmlFor?: string
+ children: ReactNode
+ className?: string
+}
+
+export function Field({
+ label,
+ hint,
+ error,
+ required,
+ htmlFor,
+ children,
+ className = '',
+}: FieldProps) {
+ return (
+
+ {label ? (
+
+ ) : null}
+ {children}
+ {error ? (
+
+
+ {error}
+
+ ) : hint ? (
+
{hint}
+ ) : null}
+
+ )
+}
+
+type InputProps = InputHTMLAttributes & {
+ invalid?: boolean
+ leadingIcon?: ReactNode
+}
+
+export const Input = forwardRef(function Input(
+ { className = '', invalid, leadingIcon, ...rest },
+ ref,
+) {
+ if (leadingIcon) {
+ return (
+
+
+ {leadingIcon}
+
+
+
+ )
+ }
+ return (
+
+ )
+})
+
+type TextareaProps = TextareaHTMLAttributes & { invalid?: boolean }
+
+export const Textarea = forwardRef(function Textarea(
+ { className = '', invalid, rows = 3, ...rest },
+ ref,
+) {
+ return (
+
+ )
+})
+
+type SelectProps = {
+ value: string
+ options: readonly SelectMenuOption[]
+ onChange: (value: string) => void
+ ariaLabel: string
+ placeholder?: string
+ disabled?: boolean
+ invalid?: boolean
+ className?: string
+ id?: string
+ required?: boolean
+}
+
+export function Select({
+ value,
+ options,
+ onChange,
+ ariaLabel,
+ placeholder,
+ disabled,
+ invalid,
+ className = '',
+}: SelectProps) {
+ return (
+
+ )
+}
+
+/** Label + control in one call for the common case. */
+export function LabeledInput({
+ label,
+ hint,
+ error,
+ required,
+ className,
+ ...rest
+}: InputProps & Pick) {
+ const id = useId()
+ return (
+
+
+
+ )
+}
+
+export function LabeledTextarea({
+ label,
+ hint,
+ error,
+ required,
+ className,
+ ...rest
+}: TextareaProps & Pick) {
+ const id = useId()
+ return (
+
+
+
+ )
+}
+
+export function LabeledSelect({
+ label,
+ hint,
+ error,
+ required,
+ className,
+ ...rest
+}: SelectProps & Pick) {
+ return (
+
+
+
+ )
+}
+
+export type { SelectMenuOption }
diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx
new file mode 100644
index 0000000..1b2746f
--- /dev/null
+++ b/src/components/ui/PageHeader.tsx
@@ -0,0 +1,41 @@
+import type { LucideIcon } from 'lucide-react'
+import type { ReactNode } from 'react'
+
+export function PageHeader({
+ title,
+ subtitle,
+ action,
+ icon: Icon,
+ kicker,
+ meta,
+}: {
+ title: string
+ subtitle?: string
+ action?: ReactNode
+ icon?: LucideIcon
+ kicker?: string
+ meta?: ReactNode
+}) {
+ return (
+
+
+ {Icon ? (
+
+
+
+ ) : null}
+
+ {kicker ? (
+
{kicker}
+ ) : null}
+
{title}
+ {subtitle ? (
+
{subtitle}
+ ) : null}
+ {meta ?
{meta}
: null}
+
+
+ {action ?
{action}
: null}
+
+ )
+}
diff --git a/src/components/ui/PageShell.tsx b/src/components/ui/PageShell.tsx
new file mode 100644
index 0000000..26694ed
--- /dev/null
+++ b/src/components/ui/PageShell.tsx
@@ -0,0 +1,29 @@
+import type { HTMLAttributes, ReactNode } from 'react'
+
+const WIDTHS = {
+ sm: 'max-w-2xl',
+ md: 'max-w-6xl',
+ lg: 'max-w-7xl',
+ full: 'max-w-[1600px]',
+} as const
+
+type Props = HTMLAttributes & {
+ children: ReactNode
+ width?: keyof typeof WIDTHS
+}
+
+export function PageShell({
+ children,
+ width = 'lg',
+ className = '',
+ ...rest
+}: Props) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/ui/SegmentedTabs.tsx b/src/components/ui/SegmentedTabs.tsx
new file mode 100644
index 0000000..7d8a459
--- /dev/null
+++ b/src/components/ui/SegmentedTabs.tsx
@@ -0,0 +1,36 @@
+export function SegmentedTabs({
+ items,
+ value,
+ onChange,
+ className = '',
+}: {
+ items: ReadonlyArray
+ value: T
+ onChange: (value: T) => void
+ className?: string
+}) {
+ return (
+
+ {items.map(([id, label]) => {
+ const active = value === id
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/ui/SelectMenu.tsx b/src/components/ui/SelectMenu.tsx
new file mode 100644
index 0000000..bf6f65e
--- /dev/null
+++ b/src/components/ui/SelectMenu.tsx
@@ -0,0 +1,354 @@
+import { Check, ChevronDown } from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
+import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+
+export type SelectMenuOption = {
+ value: string
+ label: string
+ description?: string
+ group?: string
+ icon?: LucideIcon
+ disabled?: boolean
+}
+
+type MenuPos = {
+ top: number
+ left: number
+ width: number
+ openUp: boolean
+ maxHeight: number
+}
+
+const PANEL_CLASS =
+ 'overflow-hidden rounded-2xl border border-line bg-white shadow-[0_12px_40px_-12px_rgba(23,32,51,0.28),0_4px_12px_-4px_rgba(23,32,51,0.12)]'
+
+export function SelectMenu({
+ value,
+ options,
+ onChange,
+ ariaLabel,
+ placeholder = 'انتخاب کنید',
+ disabled = false,
+ leading,
+ trailing,
+ footer,
+ size = 'md',
+ fullWidth = false,
+ matchTriggerWidth = true,
+ minMenuWidth = 200,
+ maxMenuWidth = 360,
+ triggerClassName = '',
+ invalid = false,
+ emptyLabel = 'موردی نیست',
+ iconOnly = false,
+ selectedValues,
+ closeOnSelect = true,
+ displayLabel,
+}: {
+ value: string
+ options: readonly SelectMenuOption[]
+ onChange: (value: string) => void
+ ariaLabel: string
+ placeholder?: string
+ disabled?: boolean
+ leading?: ReactNode
+ trailing?: ReactNode
+ footer?: ReactNode
+ size?: 'sm' | 'md' | 'chip'
+ fullWidth?: boolean
+ matchTriggerWidth?: boolean
+ minMenuWidth?: number
+ maxMenuWidth?: number
+ triggerClassName?: string
+ invalid?: boolean
+ emptyLabel?: string
+ iconOnly?: boolean
+ /** When set, checkmarks use this set instead of the single `value`. */
+ selectedValues?: readonly string[]
+ closeOnSelect?: boolean
+ /** Override the trigger text (e.g. first attached file while several are checked). */
+ displayLabel?: string
+}) {
+ const listId = useId()
+ const [open, setOpen] = useState(false)
+ const [pos, setPos] = useState(null)
+ const [highlight, setHighlight] = useState(-1)
+ const triggerRef = useRef(null)
+ const menuRef = useRef(null)
+
+ const selected = useMemo(
+ () => options.find((option) => option.value === value) || null,
+ [options, value],
+ )
+
+ const enabledIndexes = useMemo(
+ () => options.map((option, index) => (option.disabled ? -1 : index)).filter((index) => index >= 0),
+ [options],
+ )
+
+ function updatePosition() {
+ if (!triggerRef.current) return
+ const rect = triggerRef.current.getBoundingClientRect()
+ const width = Math.min(
+ maxMenuWidth,
+ Math.max(matchTriggerWidth ? rect.width : minMenuWidth, minMenuWidth),
+ )
+ const spaceBelow = window.innerHeight - rect.bottom - 12
+ const spaceAbove = rect.top - 12
+ const openUp = spaceBelow < 220 && spaceAbove > spaceBelow
+ const maxHeight = Math.min(320, Math.max(160, openUp ? spaceAbove : spaceBelow))
+ const left = Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)
+ setPos({
+ top: openUp ? rect.top - 6 : rect.bottom + 6,
+ left,
+ width,
+ openUp,
+ maxHeight,
+ })
+ }
+
+ useLayoutEffect(() => {
+ if (!open) return
+ updatePosition()
+ const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled)
+ setHighlight(selectedIndex >= 0 ? selectedIndex : enabledIndexes[0] ?? -1)
+ }, [open, options, value, enabledIndexes])
+
+ useEffect(() => {
+ if (!open) return
+ function onPointerDown(event: MouseEvent) {
+ const target = event.target as Node
+ if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) {
+ setOpen(false)
+ }
+ }
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ setOpen(false)
+ triggerRef.current?.focus()
+ }
+ }
+ function onReposition() {
+ updatePosition()
+ }
+ document.addEventListener('mousedown', onPointerDown)
+ document.addEventListener('keydown', onKeyDown)
+ window.addEventListener('resize', onReposition)
+ window.addEventListener('scroll', onReposition, true)
+ return () => {
+ document.removeEventListener('mousedown', onPointerDown)
+ document.removeEventListener('keydown', onKeyDown)
+ window.removeEventListener('resize', onReposition)
+ window.removeEventListener('scroll', onReposition, true)
+ }
+ }, [open])
+
+ useEffect(() => {
+ if (!open || highlight < 0) return
+ const item = menuRef.current?.querySelector(`[data-index="${highlight}"]`)
+ item?.scrollIntoView({ block: 'nearest' })
+ }, [highlight, open])
+
+ function selectValue(next: string) {
+ onChange(next)
+ if (!closeOnSelect) return
+ setOpen(false)
+ triggerRef.current?.focus()
+ }
+
+ function moveHighlight(delta: number) {
+ if (enabledIndexes.length === 0) return
+ const currentPos = enabledIndexes.indexOf(highlight)
+ const start = currentPos >= 0 ? currentPos : delta > 0 ? -1 : 0
+ const nextPos = (start + delta + enabledIndexes.length) % enabledIndexes.length
+ setHighlight(enabledIndexes[nextPos]!)
+ }
+
+ useEffect(() => {
+ if (!open) return
+ menuRef.current?.focus()
+ }, [open, pos])
+
+ function onTriggerKeyDown(event: ReactKeyboardEvent) {
+ if (disabled) return
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ setOpen(true)
+ }
+ }
+
+ function onMenuKeyDown(event: ReactKeyboardEvent) {
+ if (event.key === 'ArrowDown') {
+ event.preventDefault()
+ moveHighlight(1)
+ return
+ }
+ if (event.key === 'ArrowUp') {
+ event.preventDefault()
+ moveHighlight(-1)
+ return
+ }
+ if (event.key === 'Home') {
+ event.preventDefault()
+ setHighlight(enabledIndexes[0] ?? -1)
+ return
+ }
+ if (event.key === 'End') {
+ event.preventDefault()
+ setHighlight(enabledIndexes[enabledIndexes.length - 1] ?? -1)
+ return
+ }
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ const option = options[highlight]
+ if (option && !option.disabled) selectValue(option.value)
+ }
+ }
+
+ const isChip = size === 'chip'
+ const heightClass = isChip ? 'h-8' : size === 'sm' ? 'h-9' : 'h-10'
+ const shapeClass = isChip ? 'gap-1.5 rounded-full pr-2 pl-1.5' : 'gap-2 rounded-xl pr-3 pl-2.5'
+ const textClass = isChip ? 'text-[12px] font-medium' : 'text-sm'
+ const SelectedIcon = selected?.icon
+ const triggerLabel = displayLabel || (value && selected ? selected.label : placeholder)
+ const triggerMuted = !displayLabel && !(value && selected)
+
+ const menu =
+ open && pos
+ ? createPortal(
+
+
+ {options.length === 0 ? (
+
{emptyLabel}
+ ) : (
+ options.map((option, index) => {
+ const Icon = option.icon
+ const isSelected = selectedValues
+ ? selectedValues.includes(option.value)
+ : option.value === value
+ const isActive = index === highlight
+ const prevGroup = index > 0 ? options[index - 1]?.group : undefined
+ const showGroup = Boolean(option.group && option.group !== prevGroup)
+ return (
+
+ {showGroup ? (
+
+ {option.group}
+
+ ) : null}
+
+
+ )
+ })
+ )}
+
+ {footer ? (
+
{footer}
+ ) : null}
+
,
+ document.body,
+ )
+ : null
+
+ return (
+
+
+ {menu}
+
+ )
+}
+
+export const selectMenuPanelClass = PANEL_CLASS
diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx
new file mode 100644
index 0000000..5124a5c
--- /dev/null
+++ b/src/components/ui/Skeleton.tsx
@@ -0,0 +1,48 @@
+export function Skeleton({ className = '' }: { className?: string }) {
+ return
+}
+
+export function TableSkeleton({ rows = 6 }: { rows?: number }) {
+ return (
+
+
+
+
+ {Array.from({ length: rows }).map((_, index) => (
+
+
+
+
+
+
+ ))}
+
+ )
+}
+
+export function CardGridSkeleton({
+ count = 4,
+ className = 'grid gap-4 md:grid-cols-2 xl:grid-cols-3',
+}: {
+ count?: number
+ className?: string
+}) {
+ return (
+
+ {Array.from({ length: count }).map((_, index) => (
+
+ ))}
+
+ )
+}
diff --git a/src/components/ui/StatusBadge.tsx b/src/components/ui/StatusBadge.tsx
new file mode 100644
index 0000000..7dac57a
--- /dev/null
+++ b/src/components/ui/StatusBadge.tsx
@@ -0,0 +1,83 @@
+export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warn' | 'danger'
+
+const TONES: Record = {
+ neutral: 'bg-surface-subtle text-ink-muted ring-line',
+ accent: 'bg-accent-soft text-accent ring-accent/15',
+ success: 'bg-success-soft text-success ring-success/15',
+ warn: 'bg-warn-soft text-warn ring-warn/20',
+ danger: 'bg-danger-soft text-danger ring-danger/15',
+}
+
+export function Badge({
+ children,
+ tone = 'neutral',
+ className = '',
+}: {
+ children: React.ReactNode
+ tone?: BadgeTone
+ className?: string
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+const STATUS_TONES: Record = {
+ ready: 'success',
+ complete: 'success',
+ published: 'success',
+ processing: 'accent',
+ draft: 'neutral',
+ needs_review: 'warn',
+ inferred: 'accent',
+ confirmed: 'success',
+ unknown: 'neutral',
+ partial: 'warn',
+ archived: 'neutral',
+ insufficient: 'neutral',
+ failed: 'danger',
+ data_only: 'neutral',
+ understanding: 'accent',
+ draft_ready: 'warn',
+ TODO: 'neutral',
+ RUNNING: 'accent',
+ FINISHED: 'success',
+ FAILED: 'danger',
+ todo: 'neutral',
+ running: 'accent',
+ finished: 'success',
+}
+
+const LABELS: Record = {
+ ready: 'آماده',
+ complete: 'کامل',
+ published: 'آماده',
+ processing: 'در حال پردازش',
+ draft: 'پیشنویس',
+ needs_review: 'نیازمند بررسی',
+ inferred: 'استنتاجشده',
+ confirmed: 'تأییدشده',
+ unknown: 'نامشخص',
+ partial: 'بخشی قابل استفاده',
+ archived: 'آرشیو',
+ insufficient: 'داده ناکافی',
+ failed: 'خطا',
+ data_only: 'فقط داده',
+ understanding: 'در حال شناخت',
+ draft_ready: 'مدل اولیه آماده',
+ TODO: 'در انتظار',
+ RUNNING: 'در حال پردازش',
+ FINISHED: 'آماده',
+ FAILED: 'خطا',
+ todo: 'در انتظار',
+ running: 'در حال پردازش',
+ finished: 'آماده',
+}
+
+export function StatusBadge({ status, label }: { status: string; label?: string }) {
+ return {label || LABELS[status] || status}
+}
diff --git a/src/components/ui/Switch.tsx b/src/components/ui/Switch.tsx
new file mode 100644
index 0000000..88174a3
--- /dev/null
+++ b/src/components/ui/Switch.tsx
@@ -0,0 +1,62 @@
+type Props = {
+ checked: boolean
+ onChange: (checked: boolean) => void
+ label: string
+ /** Renders the label next to the control instead of only for screen readers. */
+ showLabel?: boolean
+ description?: string
+ disabled?: boolean
+ size?: 'sm' | 'md'
+}
+
+// Class strings must stay literal so Tailwind's scanner can see them.
+const SIZES = {
+ sm: { track: 'h-5 w-9', thumb: 'h-3.5 w-3.5', on: '-translate-x-4', off: 'translate-x-0' },
+ md: { track: 'h-6 w-11', thumb: 'h-4 w-4', on: '-translate-x-5', off: 'translate-x-0' },
+} as const
+
+export function Switch({
+ checked,
+ onChange,
+ label,
+ showLabel = false,
+ description,
+ disabled,
+ size = 'sm',
+}: Props) {
+ const style = SIZES[size]
+
+ const control = (
+
+ )
+
+ if (!showLabel) return control
+
+ return (
+
+ )
+}
diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx
new file mode 100644
index 0000000..14ed307
--- /dev/null
+++ b/src/components/ui/Toast.tsx
@@ -0,0 +1,40 @@
+import { CheckCircle2, X } from 'lucide-react'
+import { useEffect } from 'react'
+
+export function Toast({
+ message,
+ onDismiss,
+ duration = 3500,
+}: {
+ message: string | null
+ onDismiss: () => void
+ duration?: number
+}) {
+ useEffect(() => {
+ if (!message) return
+ const timer = window.setTimeout(onDismiss, duration)
+ return () => window.clearTimeout(timer)
+ }, [message, duration, onDismiss])
+
+ if (!message) return null
+
+ return (
+
+
+
+ {message}
+
+
+
+ )
+}
diff --git a/src/components/ui/ToastProvider.tsx b/src/components/ui/ToastProvider.tsx
new file mode 100644
index 0000000..03ded15
--- /dev/null
+++ b/src/components/ui/ToastProvider.tsx
@@ -0,0 +1,129 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+ type PropsWithChildren,
+} from 'react'
+import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react'
+
+export type ToastTone = 'success' | 'error' | 'warn' | 'info'
+
+type ToastItem = {
+ id: number
+ message: string
+ tone: ToastTone
+ action?: { label: string; onSelect: () => void }
+}
+
+type PushOptions = {
+ tone?: ToastTone
+ duration?: number
+ action?: ToastItem['action']
+}
+
+type ToastApi = {
+ push: (message: string, options?: PushOptions) => void
+ success: (message: string, options?: Omit) => void
+ error: (message: string, options?: Omit) => void
+ dismiss: (id: number) => void
+}
+
+const ToastContext = createContext(null)
+
+const TONES: Record = {
+ success: { icon: CheckCircle2, className: 'text-success' },
+ error: { icon: XCircle, className: 'text-danger' },
+ warn: { icon: AlertTriangle, className: 'text-warn' },
+ info: { icon: Info, className: 'text-info' },
+}
+
+export function ToastProvider({ children }: PropsWithChildren) {
+ const [items, setItems] = useState([])
+ const nextId = useRef(1)
+
+ const dismiss = useCallback((id: number) => {
+ setItems((list) => list.filter((item) => item.id !== id))
+ }, [])
+
+ const push = useCallback(
+ (message: string, options?: PushOptions) => {
+ const id = nextId.current++
+ setItems((list) => [
+ ...list.slice(-2),
+ { id, message, tone: options?.tone ?? 'success', action: options?.action },
+ ])
+ window.setTimeout(() => dismiss(id), options?.duration ?? 4000)
+ },
+ [dismiss],
+ )
+
+ const api = useMemo(
+ () => ({
+ push,
+ dismiss,
+ success: (message, options) => push(message, { ...options, tone: 'success' }),
+ error: (message, options) => push(message, { ...options, tone: 'error', duration: 6000 }),
+ }),
+ [push, dismiss],
+ )
+
+ return (
+
+ {children}
+
+ {items.map((item) => {
+ const { icon: Icon, className } = TONES[item.tone]
+ return (
+
+
+ {item.message}
+ {item.action ? (
+
+ ) : null}
+
+
+ )
+ })}
+
+
+ )
+}
+
+/**
+ * Falls back to a no-op outside a provider so components stay renderable in
+ * isolation (tests, storybook-style previews).
+ */
+export function useToast(): ToastApi {
+ const context = useContext(ToastContext)
+ return (
+ context ?? {
+ push: () => undefined,
+ success: () => undefined,
+ error: () => undefined,
+ dismiss: () => undefined,
+ }
+ )
+}
diff --git a/src/components/ui/Toolbar.tsx b/src/components/ui/Toolbar.tsx
new file mode 100644
index 0000000..ca15de5
--- /dev/null
+++ b/src/components/ui/Toolbar.tsx
@@ -0,0 +1,98 @@
+import { Search, X } from 'lucide-react'
+import type { PropsWithChildren } from 'react'
+import { SelectMenu } from './SelectMenu'
+
+export function Toolbar({ children }: PropsWithChildren) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function ToolbarSpacer() {
+ return
+}
+
+type Option = { value: string; label: string }
+
+export function FilterSelect({
+ label,
+ ariaLabel,
+ value,
+ options,
+ onChange,
+ minWidth = 'min-w-[9rem]',
+}: {
+ label: string
+ ariaLabel: string
+ value: string
+ options: Option[]
+ onChange: (value: string) => void
+ minWidth?: string
+}) {
+ const active = value !== 'all' && value !== ''
+ return (
+
+ {label}
+
+
+ )
+}
+
+export function SearchField({
+ ariaLabel,
+ value,
+ placeholder,
+ onChange,
+ onCommit,
+}: {
+ ariaLabel: string
+ value: string
+ placeholder?: string
+ onChange: (value: string) => void
+ onCommit: (value: string) => void
+}) {
+ return (
+
+
+ onChange(event.target.value)}
+ onBlur={() => onCommit(value.trim())}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') onCommit(value.trim())
+ }}
+ className="h-9 w-56 rounded-xl border border-line bg-white pr-9 pl-8 text-sm text-ink outline-none transition placeholder:text-ink-muted/70 focus:border-accent/40 focus:shadow-[0_0_0_3px_rgba(37,99,235,0.08)]"
+ />
+ {value ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx
new file mode 100644
index 0000000..b238eea
--- /dev/null
+++ b/src/components/ui/Tooltip.tsx
@@ -0,0 +1,88 @@
+import { useId, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+
+type Placement = 'top' | 'bottom' | 'start' | 'end'
+
+type Props = {
+ label: ReactNode
+ placement?: Placement
+ children: ReactElement<{
+ onMouseEnter?: (event: unknown) => void
+ onMouseLeave?: (event: unknown) => void
+ onFocus?: (event: unknown) => void
+ onBlur?: (event: unknown) => void
+ 'aria-describedby'?: string
+ }>
+}
+
+const GAP = 8
+
+/** Hover/focus tooltip rendered in a portal so it escapes overflow containers. */
+export function Tooltip({ label, placement = 'bottom', children }: Props) {
+ const id = useId()
+ const [open, setOpen] = useState(false)
+ const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
+ const wrapperRef = useRef(null)
+ const bubbleRef = useRef(null)
+
+ useLayoutEffect(() => {
+ if (!open) return
+ const anchor = wrapperRef.current?.firstElementChild ?? wrapperRef.current
+ const bubble = bubbleRef.current
+ if (!anchor || !bubble) return
+
+ const a = anchor.getBoundingClientRect()
+ const b = bubble.getBoundingClientRect()
+ let top = a.bottom + GAP
+ let left = a.left + a.width / 2 - b.width / 2
+
+ if (placement === 'top') top = a.top - b.height - GAP
+ if (placement === 'start' || placement === 'end') {
+ top = a.top + a.height / 2 - b.height / 2
+ const isRtl = document.documentElement.dir === 'rtl'
+ const toLeft = placement === (isRtl ? 'end' : 'start')
+ left = toLeft ? a.left - b.width - GAP : a.right + GAP
+ }
+
+ const margin = 8
+ left = Math.min(Math.max(margin, left), window.innerWidth - b.width - margin)
+ top = Math.min(Math.max(margin, top), window.innerHeight - b.height - margin)
+ setPos({ top, left })
+ }, [open, placement])
+
+ const show = () => setOpen(true)
+ const hide = () => setOpen(false)
+
+ return (
+ <>
+
+ {children}
+
+ {open
+ ? createPortal(
+
+ {label}
+
,
+ document.body,
+ )
+ : null}
+ >
+ )
+}
diff --git a/src/components/ui/useFocusTrap.ts b/src/components/ui/useFocusTrap.ts
new file mode 100644
index 0000000..eaffb94
--- /dev/null
+++ b/src/components/ui/useFocusTrap.ts
@@ -0,0 +1,66 @@
+import { useEffect, type RefObject } from 'react'
+
+const FOCUSABLE = [
+ 'a[href]',
+ 'button:not([disabled])',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ '[tabindex]:not([tabindex="-1"])',
+].join(',')
+
+/**
+ * Keeps keyboard focus inside an overlay while it is open and restores it to the
+ * trigger on close, so dialogs and drawers behave like native modals.
+ */
+export function useFocusTrap(ref: RefObject, active: boolean) {
+ useEffect(() => {
+ if (!active) return
+ const container = ref.current
+ if (!container) return
+
+ const previouslyFocused = document.activeElement as HTMLElement | null
+ const initial = container.querySelector(FOCUSABLE)
+ ;(initial ?? container).focus({ preventScroll: true })
+
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key !== 'Tab') return
+ const node = ref.current
+ if (!node) return
+ const items = Array.from(node.querySelectorAll(FOCUSABLE)).filter(
+ (item) => item.offsetParent !== null || item === document.activeElement,
+ )
+ if (items.length === 0) {
+ event.preventDefault()
+ return
+ }
+ const first = items[0]
+ const last = items[items.length - 1]
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault()
+ last.focus()
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault()
+ first.focus()
+ }
+ }
+
+ document.addEventListener('keydown', onKeyDown)
+ return () => {
+ document.removeEventListener('keydown', onKeyDown)
+ previouslyFocused?.focus?.({ preventScroll: true })
+ }
+ }, [ref, active])
+}
+
+/** Prevents the page behind an overlay from scrolling while it is open. */
+export function useBodyScrollLock(active: boolean) {
+ useEffect(() => {
+ if (!active) return
+ const previous = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => {
+ document.body.style.overflow = previous
+ }
+ }, [active])
+}
diff --git a/src/contracts/chat.ts b/src/contracts/chat.ts
new file mode 100644
index 0000000..24db796
--- /dev/null
+++ b/src/contracts/chat.ts
@@ -0,0 +1,21 @@
+import type { ResourceRef } from './common'
+
+export interface ChatRequest {
+ conversation_id?: string
+ message: string
+ resources: ResourceRef[]
+ skill_id?: string
+ model_id?: string
+ mode?: 'auto' | 'analysis'
+ parent_run_id?: string
+ temperature?: number
+ max_new_tokens?: number
+ reasoning_mode?: 'fast' | 'medium' | 'deep'
+}
+
+export interface ChatResponse {
+ run_id: string
+ conversation_id: string
+ message_id: string
+ status: string
+}
diff --git a/src/contracts/common.ts b/src/contracts/common.ts
new file mode 100644
index 0000000..54f1bf5
--- /dev/null
+++ b/src/contracts/common.ts
@@ -0,0 +1,42 @@
+export type ResourceType = 'file' | 'datasource' | 'knowledge'
+
+export interface ResourceRef {
+ id: string
+ type: ResourceType
+ name?: string
+}
+
+export interface ResourceView extends ResourceRef {
+ name: string
+ status?: string
+ icon?: string
+ subtitle?: string
+}
+
+export const LIVE_RUN_STATUSES = [
+ 'created',
+ 'queued',
+ 'preparing',
+ 'running',
+ 'waiting_for_user',
+ 'executing',
+ 'streaming',
+] as const
+
+export function isLiveRunStatus(status?: string | null): boolean {
+ return Boolean(status && (LIVE_RUN_STATUSES as readonly string[]).includes(status))
+}
+
+export type RunStatus =
+ | 'created'
+ | 'queued'
+ | 'preparing'
+ | 'running'
+ | 'waiting_for_user'
+ | 'executing'
+ | 'streaming'
+ | 'completed'
+ | 'failed'
+ | 'cancelled'
+
+export type FileStatus = 'queued' | 'uploading' | 'processing' | 'ready' | 'failed' | 'cancelled'
diff --git a/src/contracts/errors.ts b/src/contracts/errors.ts
new file mode 100644
index 0000000..9a6fca0
--- /dev/null
+++ b/src/contracts/errors.ts
@@ -0,0 +1,25 @@
+export interface KamidsoError {
+ code: string
+ message: string
+ retryable?: boolean
+ run_id?: string
+}
+
+export const ERROR_MESSAGES: Record = {
+ ENGINE_UNAVAILABLE: 'موتور تحلیل در حال حاضر در دسترس نیست.',
+ FILE_TOO_LARGE: 'حجم فایل بیشتر از حد مجاز است.',
+ RESOURCE_ACCESS_DENIED: 'دستریرسی به این منبع برای شما مجاز نیست.',
+ NOT_FOUND: 'مورد درخواستی یافت نشد.',
+ UPLOAD_FAILED: 'بارگذاری فایل ناموفق بود.',
+}
+
+export function normalizeError(err: unknown): KamidsoError {
+ if (err && typeof err === 'object') {
+ const obj = err as Record
+ const code = String(obj.code ?? obj.errorCode ?? 'UNKNOWN')
+ const message =
+ String(obj.message ?? obj.err_msg ?? ERROR_MESSAGES[code] ?? 'خطایی رخ داد.')
+ return { code, message, retryable: Boolean(obj.retryable) }
+ }
+ return { code: 'UNKNOWN', message: 'خطایی رخ داد.' }
+}
diff --git a/src/contracts/event.ts b/src/contracts/event.ts
new file mode 100644
index 0000000..d919f78
--- /dev/null
+++ b/src/contracts/event.ts
@@ -0,0 +1,40 @@
+export const CANONICAL_EVENT_TYPES = [
+ 'run.started',
+ 'run.preparing',
+ 'run.created',
+ 'plan.updated',
+ 'plan.update',
+ 'step.started',
+ 'step.start',
+ 'step.updated',
+ 'step.meta',
+ 'step.output',
+ 'step.chunk',
+ 'step.completed',
+ 'step.done',
+ 'step.failed',
+ 'question.requested',
+ 'question.asked',
+ 'question.resolved',
+ 'question.replied',
+ 'question.rejected',
+ 'artifact.created',
+ 'answer.delta',
+ 'answer.completed',
+ 'answer.started',
+ 'run.completed',
+ 'run.failed',
+ 'run.cancelled',
+ 'final',
+ 'done',
+ 'context.status',
+] as const
+
+export type KamidsoEventType = (typeof CANONICAL_EVENT_TYPES)[number]
+
+export interface KamidsoRunEvent {
+ type: string
+ run_id?: string
+ sequence?: number
+ [key: string]: unknown
+}
diff --git a/src/contracts/file.ts b/src/contracts/file.ts
new file mode 100644
index 0000000..acee4be
--- /dev/null
+++ b/src/contracts/file.ts
@@ -0,0 +1,31 @@
+import type { FileStatus } from './common'
+
+export interface KamidsoFile {
+ id: string
+ name: string
+ size_bytes: number
+ mime_type: string
+ status: 'processing' | 'ready' | 'failed'
+ created_at: string
+}
+
+export interface FilePreview {
+ kind: 'spreadsheet' | 'table' | 'text' | 'document' | 'image' | 'json' | 'unsupported' | 'binary' | 'tabular'
+ metadata: Record
+ data?: unknown
+ type?: string
+ columns?: string[]
+ rows?: unknown[][]
+ sample?: unknown
+}
+
+export interface WorkspaceFileSnapshot {
+ file_id: string
+ name: string
+ size: number
+ media_type: string
+ kind: string
+ status: FileStatus | 'uploading' | 'inspecting' | 'preview_failed' | 'deleted'
+ ordinal: number
+ error_code?: string | null
+}
diff --git a/src/contracts/index.ts b/src/contracts/index.ts
new file mode 100644
index 0000000..2db98a2
--- /dev/null
+++ b/src/contracts/index.ts
@@ -0,0 +1,7 @@
+export * from './common'
+export * from './chat'
+export * from './event'
+export * from './run'
+export * from './file'
+export * from './errors'
+export * from './knowledge'
diff --git a/src/contracts/knowledge.ts b/src/contracts/knowledge.ts
new file mode 100644
index 0000000..9265818
--- /dev/null
+++ b/src/contracts/knowledge.ts
@@ -0,0 +1,65 @@
+export type KnowledgeDocStatus = 'TODO' | 'RUNNING' | 'FINISHED' | 'FAILED' | string
+export type KnowledgeSpaceStatus = 'ready' | 'processing' | 'partial' | 'failed' | string
+
+export interface KnowledgeChunkSettings {
+ strategy?: string
+ chunk_size?: number
+ chunk_overlap?: number
+ topk?: number
+ recall_score?: number
+}
+
+export interface KnowledgeSpace {
+ id: string
+ name: string
+ description?: string
+ status: KnowledgeSpaceStatus
+ vector_type?: string
+ document_count: number
+ chunk_count: number
+ context?: {
+ embedding?: KnowledgeChunkSettings
+ }
+}
+
+export interface KnowledgeDocument {
+ id: string
+ name: string
+ doc_type: string
+ status: KnowledgeDocStatus
+ chunk_size: number
+ result?: string
+ summary?: string
+ content?: string
+ file_id?: string | null
+}
+
+export interface KnowledgeChunk {
+ id: string
+ document_id: string
+ doc_name?: string
+ content: string
+ meta_info?: string
+}
+
+export interface KnowledgeChunkPage {
+ items: KnowledgeChunk[]
+ total: number
+ page: number
+ page_size: number
+}
+
+export interface KnowledgeRetrieveHit {
+ chunk_id?: string
+ content: string
+ score: number
+ doc_name?: string
+}
+
+export interface KnowledgeSourceInput {
+ type: 'file' | 'url' | 'text'
+ file_id?: string
+ url?: string
+ text?: string
+ title?: string
+}
diff --git a/src/contracts/report.ts b/src/contracts/report.ts
new file mode 100644
index 0000000..0b9d181
--- /dev/null
+++ b/src/contracts/report.ts
@@ -0,0 +1,245 @@
+export const REPORT_SCHEMA_VERSION = 2
+export const REPORT_SCHEMA_VERSIONS = [1, 2] as const
+export const MAX_TABLE_ROWS = 50
+
+export type ChartType = 'bar' | 'line' | 'area' | 'pie' | 'donut' | 'scatter'
+export type CalloutVariant = 'info' | 'success' | 'warning' | 'danger'
+export type Trend = 'up' | 'down' | 'flat'
+export type TextFormat = 'plain' | 'markdown'
+
+export interface ReportBlockBase {
+ id?: string
+ span?: number
+ pageBreakBefore?: boolean
+}
+
+export interface HeadingBlock extends ReportBlockBase {
+ type: 'heading'
+ level: 1 | 2 | 3
+ text: string
+}
+
+export interface TextBlock extends ReportBlockBase {
+ type: 'text'
+ content: string
+ format?: TextFormat
+}
+
+export interface KpiChange {
+ value?: string | number
+ direction?: Trend
+ label?: string
+}
+
+export interface KpiItem {
+ label: string
+ value?: string | number
+ formatted_value?: string
+ unit?: string
+ delta?: string
+ deltaLabel?: string
+ trend?: Trend
+ change?: KpiChange
+}
+
+export interface KpiBlock extends ReportBlockBase, KpiItem {
+ type: 'kpi'
+}
+
+export interface KpiGroupBlock extends ReportBlockBase {
+ type: 'kpi_group'
+ items: KpiItem[]
+}
+
+export interface TableColumn {
+ field?: string
+ label?: string
+ title?: string
+ dataIndex?: string
+ key?: string
+}
+
+export interface TableBlock extends ReportBlockBase {
+ type: 'table'
+ title?: string
+ dataset?: string
+ columns: Array
+ rows: unknown[]
+}
+
+export interface ChartSeries {
+ field: string
+ label?: string
+}
+
+export interface ChartBlock extends ReportBlockBase {
+ type: 'chart'
+ chartType?: ChartType
+ title?: string
+ dataset?: string
+ data?: Record[]
+ x: string
+ /** V1 single series; V2 prefers series[] */
+ y?: string | string[]
+ series?: ChartSeries[]
+ stacked?: boolean
+ legend?: boolean
+ showGrid?: boolean
+ showTooltip?: boolean
+ height?: number
+ xAxisLabel?: string
+ yAxisLabel?: string
+ valueFormat?: string
+}
+
+export interface CalloutBlock extends ReportBlockBase {
+ type: 'callout'
+ variant?: CalloutVariant
+ title?: string
+ text: string
+ content?: string
+}
+
+export interface ImageBlock extends ReportBlockBase {
+ type: 'image'
+ src: string
+ file_id?: string
+ artifact_id?: string
+ title?: string
+ caption?: string
+ alt?: string
+}
+
+export interface QuoteBlock extends ReportBlockBase {
+ type: 'quote'
+ content: string
+ source?: string
+}
+
+export interface DividerBlock extends ReportBlockBase {
+ type: 'divider'
+}
+
+export interface SpacerBlock extends ReportBlockBase {
+ type: 'spacer'
+ size?: number | string
+}
+
+export type ReportBlock =
+ | HeadingBlock
+ | TextBlock
+ | KpiBlock
+ | KpiGroupBlock
+ | TableBlock
+ | ChartBlock
+ | CalloutBlock
+ | ImageBlock
+ | QuoteBlock
+ | DividerBlock
+ | SpacerBlock
+
+export type ReportDataset = Record[] | { ref: string }
+
+export interface KamidsoReport {
+ version: number
+ report_id?: string
+ title: string
+ subtitle?: string
+ description?: string
+ locale?: string
+ summary?: string
+ datasets?: Record
+ blocks: ReportBlock[]
+ metadata?: Record
+ warnings?: string[]
+}
+
+export function isKamidsoReport(value: unknown): value is KamidsoReport {
+ if (!value || typeof value !== 'object') return false
+ const report = value as KamidsoReport
+ return (
+ (report.version === 1 || report.version === 2) &&
+ typeof report.title === 'string' &&
+ Array.isArray(report.blocks) &&
+ report.blocks.length > 0
+ )
+}
+
+export function resolveDatasetRows(
+ report: KamidsoReport,
+ datasetName?: string,
+): Record[] {
+ if (!datasetName || !report.datasets) return []
+ const entry = report.datasets[datasetName]
+ if (Array.isArray(entry)) return entry as Record[]
+ return []
+}
+
+export function resolveChartData(block: ChartBlock, report?: KamidsoReport): Record[] {
+ if (Array.isArray(block.data) && block.data.length) return block.data
+ if (block.dataset && report) return resolveDatasetRows(report, block.dataset)
+ return Array.isArray(block.data) ? block.data : []
+}
+
+export function normalizeChartSeries(block: ChartBlock): ChartSeries[] {
+ if (Array.isArray(block.series) && block.series.length) {
+ return block.series
+ .map((entry): ChartSeries | null => {
+ if (typeof entry === 'string') return { field: entry, label: entry }
+ const field = String(entry.field || '').trim()
+ if (!field) return null
+ return { field, label: entry.label || field }
+ })
+ .filter((x): x is ChartSeries => x !== null)
+ }
+ if (Array.isArray(block.y)) {
+ return block.y.filter(Boolean).map((field) => ({ field: String(field), label: String(field) }))
+ }
+ if (typeof block.y === 'string' && block.y.trim()) {
+ return [{ field: block.y.trim(), label: block.y.trim() }]
+ }
+ return [{ field: 'value', label: 'value' }]
+}
+
+export function normalizeChartSpec(
+ block: ChartBlock,
+ report?: KamidsoReport,
+): Record {
+ const series = normalizeChartSeries(block)
+ const data = resolveChartData(block, report)
+ return {
+ chartType: block.chartType ?? 'bar',
+ title: block.title,
+ data,
+ x: block.x || 'label',
+ y: series.length === 1 ? series[0].field : series.map((s) => s.field),
+ series,
+ stacked: block.stacked === true,
+ legend: block.legend,
+ showGrid: block.showGrid,
+ height: block.height,
+ xAxisLabel: block.xAxisLabel,
+ yAxisLabel: block.yAxisLabel,
+ valueFormat: block.valueFormat,
+ }
+}
+
+export function spanClass(span?: number): string {
+ const n = !span || span < 1 || span > 12 ? 12 : span
+ // Mobile always full width; md+ uses 12-col fractions.
+ const map: Record = {
+ 1: 'col-span-12 md:col-span-1',
+ 2: 'col-span-12 md:col-span-2',
+ 3: 'col-span-12 md:col-span-3',
+ 4: 'col-span-12 md:col-span-4',
+ 5: 'col-span-12 md:col-span-5',
+ 6: 'col-span-12 md:col-span-6',
+ 7: 'col-span-12 md:col-span-7',
+ 8: 'col-span-12 md:col-span-8',
+ 9: 'col-span-12 md:col-span-9',
+ 10: 'col-span-12 md:col-span-10',
+ 11: 'col-span-12 md:col-span-11',
+ 12: 'col-span-12',
+ }
+ return map[n] || map[12]
+}
diff --git a/src/contracts/run.ts b/src/contracts/run.ts
new file mode 100644
index 0000000..5e9350c
--- /dev/null
+++ b/src/contracts/run.ts
@@ -0,0 +1,72 @@
+import type { ResourceRef, RunStatus } from './common'
+import type { KamidsoError } from './errors'
+
+export type ExecutionStepType =
+ | 'planning'
+ | 'file_inspection'
+ | 'database_query'
+ | 'knowledge_search'
+ | 'code_execution'
+ | 'tool_execution'
+ | 'artifact_generation'
+ | 'finalization'
+
+export interface ExecutionStep {
+ id: string
+ type?: ExecutionStepType | string
+ title?: string
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'done'
+ detail?: string
+ phase?: string
+ action?: string
+ actionInput?: unknown
+ started_at?: string
+ completed_at?: string
+ output?: unknown
+ step?: number
+}
+
+export interface PlanTask {
+ content: string
+ status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
+ priority?: 'high' | 'medium' | 'low'
+}
+
+export type ArtifactType =
+ | 'table'
+ | 'chart'
+ | 'file'
+ | 'image'
+ | 'markdown'
+ | 'html'
+ | 'json'
+ | 'code'
+ | 'report'
+
+export interface KamidsoArtifact {
+ id: string
+ type: ArtifactType
+ name: string
+ content: unknown
+ filePath?: string
+}
+
+export interface PendingQuestion {
+ id: string
+ runId?: string
+ question: string
+ options?: Array<{ id: string; label: string }>
+}
+
+export interface KamidsoRun {
+ id: string
+ conversation_id: string
+ status: RunStatus
+ input?: { message?: string }
+ resources?: ResourceRef[]
+ steps?: ExecutionStep[]
+ artifacts?: KamidsoArtifact[]
+ answer?: string
+ question?: PendingQuestion
+ error?: KamidsoError
+}
diff --git a/src/e2e/golden-scenario-1.excel-analysis.e2e.test.tsx b/src/e2e/golden-scenario-1.excel-analysis.e2e.test.tsx
new file mode 100644
index 0000000..c8fc462
--- /dev/null
+++ b/src/e2e/golden-scenario-1.excel-analysis.e2e.test.tsx
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { runReducer } from '../features/assistant/stream/eventReducer'
+import { initialRunState } from '../features/assistant/state/runState'
+
+/** Golden scenario 1: Upload Excel → plan → code step → final answer (reducer-level). */
+describe('golden scenario 1 — excel analysis flow', () => {
+ it('reconstructs plan, step, and answer from canonical events', () => {
+ let state = runReducer(initialRunState(), { type: 'RUN_CREATED', runId: 'run_1', viewId: 'v1' })
+
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'plan.updated', tasks: [{ content: 'بررسی ساختار فایل', status: 'in_progress' }] },
+ })
+ expect(state.plan[0]?.content).toContain('بررسی')
+
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'step.started', id: 's1', title: 'تحلیل داده', step_type: 'code_execution' },
+ })
+ expect(state.executionByViewId.v1?.steps[0]?.title).toBe('تحلیل داده')
+
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'answer.completed', content: 'حقوق مصطفی ۱۲٬۰۰۰٬۰۰۰ تومان است.' },
+ })
+ expect(state.answer).toContain('حقوق')
+ expect(state.status).toBe('completed')
+ })
+})
diff --git a/src/e2e/golden-scenario-5.clarification.e2e.test.tsx b/src/e2e/golden-scenario-5.clarification.e2e.test.tsx
new file mode 100644
index 0000000..5a6d41f
--- /dev/null
+++ b/src/e2e/golden-scenario-5.clarification.e2e.test.tsx
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+import { runReducer } from '../features/assistant/stream/eventReducer'
+import { initialRunState } from '../features/assistant/state/runState'
+
+describe('golden scenario 5 — clarification', () => {
+ it('enters waiting_for_user on question.requested', () => {
+ const state = runReducer(initialRunState(), {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: {
+ type: 'question.requested',
+ id: 'q1',
+ run_id: 'run_1',
+ question: 'منظورتان کدام بازه زمانی است؟',
+ },
+ })
+ expect(state.status).toBe('waiting_for_user')
+ expect(state.question?.question).toContain('بازه')
+ })
+})
diff --git a/src/features/assistant/adapters/historyToMessages.test.ts b/src/features/assistant/adapters/historyToMessages.test.ts
new file mode 100644
index 0000000..c80ac7e
--- /dev/null
+++ b/src/features/assistant/adapters/historyToMessages.test.ts
@@ -0,0 +1,188 @@
+import { describe, expect, it } from 'vitest'
+import { collectTurnInputFiles, collectPinnedResources, historyToMessages } from './historyToMessages'
+import type { ChatMessage } from '../types/chat'
+
+describe('historyToMessages', () => {
+ it('pairs stored user/assistant messages and synthesizes a live view', () => {
+ const mapped = historyToMessages(
+ [
+ { id: 'm1', role: 'user', content: 'گزارش بده', resource_refs: [{ id: 'f1', type: 'file', name: 'a.xlsx' }] },
+ { id: 'm2', role: 'assistant', content: 'خلاصه قبلی' },
+ { id: 'm3', role: 'user', content: 'ادامه بده' },
+ ],
+ { id: 'run_live', status: 'running' },
+ )
+
+ expect(mapped).toHaveLength(4)
+ expect(mapped[0].role).toBe('human')
+ expect(mapped[0].attachedFiles?.[0]?.file_id).toBe('f1')
+ expect(mapped[1].role).toBe('view')
+ expect(mapped[1].context).toBe('خلاصه قبلی')
+ expect(mapped[2].role).toBe('human')
+ expect(mapped[3]).toMatchObject({
+ id: 'view-run_live',
+ role: 'view',
+ thinking: true,
+ context: '',
+ })
+ })
+
+ it('copies latest-run file resources onto the last user message when refs are missing', () => {
+ const mapped = historyToMessages(
+ [
+ { id: 'm1', role: 'user', content: 'ده مشتری برتر را نشان بده' },
+ { id: 'm2', role: 'assistant', content: 'خلاصه' },
+ ],
+ {
+ id: 'run_1',
+ status: 'completed',
+ resources: [{ resource_id: 'file_book1', resource_type: 'file', resource_name: 'Book1 (1)' }],
+ },
+ )
+
+ expect(mapped[0].attachedFiles?.[0]).toMatchObject({ file_id: 'file_book1', name: 'Book1 (1)' })
+ })
+
+ it('maps knowledge and datasource refs onto the user turn', () => {
+ const mapped = historyToMessages([
+ {
+ id: 'm1',
+ role: 'user',
+ content: 'ویراگلد چیست؟',
+ resource_refs: [
+ { id: 'kb_1', type: 'knowledge', name: 'پاسخ فنی واحد معاملات' },
+ { id: 'ds_1', type: 'datasource', name: 'sales-db' },
+ { id: 'ds_2', type: 'datasource', name: 'ops-mongo' },
+ ],
+ },
+ ])
+
+ expect(mapped[0].attachedKnowledge).toEqual({ id: 'kb_1', name: 'پاسخ فنی واحد معاملات' })
+ expect(mapped[0].attachedDb).toEqual({ id: 'ds_1', db_name: 'sales-db', db_type: '' })
+ expect(mapped[0].attachedDatabases).toEqual([
+ { id: 'ds_1', db_name: 'sales-db', db_type: '' },
+ { id: 'ds_2', db_name: 'ops-mongo', db_type: '' },
+ ])
+ })
+})
+
+describe('collectTurnInputFiles', () => {
+ it('keeps earlier attached files visible when the latest turn has none', () => {
+ const messages: ChatMessage[] = [
+ {
+ id: 'h1',
+ role: 'human',
+ context: 'این فایل',
+ order: 1,
+ attachedFiles: [
+ {
+ file_id: 'file_book1',
+ name: 'Book1 (1)',
+ size: 12,
+ media_type: 'application/xlsx',
+ kind: 'excel',
+ status: 'ready',
+ ordinal: 0,
+ },
+ ],
+ },
+ { id: 'v1', role: 'view', context: 'ok', order: 1 },
+ { id: 'h2', role: 'human', context: 'ده مشتری برتر را نشان بده', order: 2 },
+ ]
+
+ const files = collectTurnInputFiles(messages, [])
+ expect(files).toHaveLength(1)
+ expect(files[0]?.file_id).toBe('file_book1')
+ })
+
+ it('includes composer drafts so a library attach appears before send', () => {
+ const files = collectTurnInputFiles([], [
+ {
+ file_id: 'file_new',
+ name: 'sales.xlsx',
+ size: 8,
+ media_type: 'application/xlsx',
+ kind: 'excel',
+ status: 'ready',
+ ordinal: 0,
+ },
+ ])
+ expect(files[0]?.name).toBe('sales.xlsx')
+ })
+})
+
+describe('collectPinnedResources', () => {
+ it('keeps files from earlier turns as conversation pins', () => {
+ const messages: ChatMessage[] = [
+ {
+ id: 'h1',
+ role: 'human',
+ context: 'این فایل',
+ order: 1,
+ attachedFiles: [
+ {
+ file_id: 'file_book1',
+ name: 'Book1 (1)',
+ size: 12,
+ media_type: 'application/xlsx',
+ kind: 'excel',
+ status: 'ready',
+ ordinal: 0,
+ },
+ ],
+ attachedDb: { db_name: 'MongoDB', db_type: 'mongodb' },
+ },
+ { id: 'v1', role: 'view', context: 'ok', order: 1 },
+ { id: 'h2', role: 'human', context: 'فروش را بر اساس شعبه تفکیک کن', order: 2 },
+ ]
+
+ const pins = collectPinnedResources(messages)
+ expect(pins.files.map((file) => file.file_id)).toEqual(['file_book1'])
+ expect(pins.database).toEqual({ db_name: 'MongoDB', db_type: 'mongodb' })
+ expect(pins.databases).toEqual([{ db_name: 'MongoDB', db_type: 'mongodb' }])
+ })
+
+ it('unions files attached on different turns', () => {
+ const messages: ChatMessage[] = [
+ {
+ id: 'h1',
+ role: 'human',
+ context: 'فایل اول',
+ order: 1,
+ attachedFiles: [
+ {
+ file_id: 'file_a',
+ name: 'a.xlsx',
+ size: 1,
+ media_type: 'application/xlsx',
+ kind: 'excel',
+ status: 'ready',
+ ordinal: 0,
+ },
+ ],
+ },
+ {
+ id: 'h2',
+ role: 'human',
+ context: 'فایل دوم',
+ order: 2,
+ attachedFiles: [
+ {
+ file_id: 'file_b',
+ name: 'b.xlsx',
+ size: 1,
+ media_type: 'application/xlsx',
+ kind: 'excel',
+ status: 'ready',
+ ordinal: 0,
+ },
+ ],
+ attachedKnowledge: { id: 'kb_1', name: 'اسناد' },
+ },
+ ]
+
+ const pins = collectPinnedResources(messages)
+ expect(pins.files.map((file) => file.file_id)).toEqual(['file_a', 'file_b'])
+ expect(pins.knowledge).toEqual({ id: 'kb_1', name: 'اسناد' })
+ })
+})
diff --git a/src/features/assistant/adapters/historyToMessages.ts b/src/features/assistant/adapters/historyToMessages.ts
new file mode 100644
index 0000000..4991dab
--- /dev/null
+++ b/src/features/assistant/adapters/historyToMessages.ts
@@ -0,0 +1,221 @@
+import { isLiveRunStatus } from '../../../contracts/common'
+import type { ChatMessage } from '../types/chat'
+import type { SessionFileSnapshot } from '../session-files/types'
+
+export type HistoryResourceRef = {
+ id?: string
+ resource_id?: string
+ type?: string
+ resource_type?: string
+ name?: string
+ resource_name?: string
+}
+
+export type HistoryMessage = {
+ id: string
+ role: string
+ content: string
+ run_id?: string | null
+ resource_refs?: HistoryResourceRef[]
+}
+
+export type HistoryLatestRun = {
+ id: string
+ status: string
+ answer?: string
+ resources?: HistoryResourceRef[]
+}
+
+function refType(ref: HistoryResourceRef): string {
+ return String(ref.type || ref.resource_type || 'file')
+}
+
+function refId(ref: HistoryResourceRef): string {
+ return String(ref.id || ref.resource_id || '')
+}
+
+function refName(ref: HistoryResourceRef): string {
+ return String(ref.name || ref.resource_name || '')
+}
+
+export function refsToFiles(refs: HistoryResourceRef[] | undefined): SessionFileSnapshot[] {
+ return (refs ?? [])
+ .filter((ref) => refType(ref) === 'file')
+ .map((ref, index) => ({
+ file_id: refId(ref),
+ name: refName(ref) || 'file',
+ size: 0,
+ media_type: 'application/octet-stream',
+ kind: 'file',
+ status: 'ready' as const,
+ ordinal: index,
+ }))
+ .filter((file) => file.file_id)
+}
+
+export function refsToKnowledge(
+ refs: HistoryResourceRef[] | undefined,
+): ChatMessage['attachedKnowledge'] | undefined {
+ const hit = (refs ?? []).find((ref) => refType(ref) === 'knowledge')
+ if (!hit) return undefined
+ const id = refId(hit)
+ const name = refName(hit) || id
+ if (!id && !name) return undefined
+ return { id, name }
+}
+
+export function refsToDatabases(
+ refs: HistoryResourceRef[] | undefined,
+): NonNullable {
+ return (refs ?? [])
+ .filter((ref) => refType(ref) === 'datasource')
+ .map((ref) => {
+ const id = refId(ref)
+ const name = refName(ref) || id
+ return { id: id || undefined, db_name: name, db_type: '' }
+ })
+ .filter((item) => item.db_name)
+}
+
+export function refsToDatabase(
+ refs: HistoryResourceRef[] | undefined,
+): ChatMessage['attachedDb'] | undefined {
+ return refsToDatabases(refs)[0]
+}
+
+export function humanForView(
+ messages: readonly ChatMessage[],
+ viewId?: string | null,
+): ChatMessage | undefined {
+ if (viewId) {
+ const viewIndex = messages.findIndex((msg) => msg.id === viewId)
+ if (viewIndex > 0) {
+ for (let index = viewIndex - 1; index >= 0; index -= 1) {
+ if (messages[index]?.role === 'human') return messages[index]
+ }
+ }
+ }
+ return [...messages].reverse().find((msg) => msg.role === 'human')
+}
+
+export function collectPinnedResources(messages: readonly ChatMessage[]): {
+ files: SessionFileSnapshot[]
+ database?: ChatMessage['attachedDb']
+ databases: NonNullable
+ knowledge?: ChatMessage['attachedKnowledge']
+} {
+ const files = collectTurnInputFiles(messages, [])
+ const databases: Array<{ id?: string; db_name: string; db_type: string }> = []
+ const seen = new Set()
+ for (const msg of messages) {
+ if (msg.role !== 'human') continue
+ const items = msg.attachedDatabases?.length
+ ? msg.attachedDatabases
+ : msg.attachedDb?.db_name
+ ? [msg.attachedDb]
+ : []
+ for (const item of items) {
+ const key = ('id' in item && item.id) || item.db_name
+ if (!key || seen.has(key)) continue
+ seen.add(key)
+ databases.push({
+ id: 'id' in item ? item.id : undefined,
+ db_name: item.db_name,
+ db_type: item.db_type,
+ })
+ }
+ }
+ const lastHumanWithKnowledge = [...messages]
+ .reverse()
+ .find((msg) => msg.role === 'human' && msg.attachedKnowledge?.name)
+ return {
+ files,
+ database: databases[databases.length - 1],
+ databases,
+ knowledge: lastHumanWithKnowledge?.attachedKnowledge,
+ }
+}
+
+export function collectTurnInputFiles(
+ messages: readonly ChatMessage[],
+ draftSnapshots: readonly (SessionFileSnapshot | null | undefined)[],
+): SessionFileSnapshot[] {
+ const map = new Map()
+ for (const msg of messages) {
+ if (msg.role !== 'human') continue
+ for (const file of msg.attachedFiles ?? []) {
+ if (file?.file_id) map.set(file.file_id, file)
+ }
+ }
+ for (const file of draftSnapshots) {
+ if (file?.file_id) map.set(file.file_id, file)
+ }
+ return [...map.values()]
+}
+
+export function historyToMessages(
+ messages: HistoryMessage[],
+ latestRun?: HistoryLatestRun | null,
+): ChatMessage[] {
+ const mapped: ChatMessage[] = []
+ let order = 0
+ for (const msg of messages) {
+ const role = String(msg.role || '')
+ if (role === 'user' || role === 'human') {
+ order += 1
+ mapped.push({
+ id: msg.id,
+ role: 'human',
+ context: msg.content || '',
+ order,
+ attachedFiles: refsToFiles(msg.resource_refs),
+ attachedKnowledge: refsToKnowledge(msg.resource_refs),
+ attachedDatabases: refsToDatabases(msg.resource_refs),
+ attachedDb: refsToDatabase(msg.resource_refs),
+ })
+ } else if (role === 'assistant' || role === 'view') {
+ mapped.push({
+ id: msg.id,
+ role: 'view',
+ context: msg.content || '',
+ order,
+ thinking: false,
+ runId: msg.run_id || undefined,
+ })
+ }
+ }
+
+ const lastHuman = [...mapped].reverse().find((msg) => msg.role === 'human')
+ if (lastHuman && latestRun?.resources?.length) {
+ if (!lastHuman.attachedFiles?.length) {
+ lastHuman.attachedFiles = refsToFiles(latestRun.resources)
+ }
+ if (!lastHuman.attachedKnowledge) {
+ lastHuman.attachedKnowledge = refsToKnowledge(latestRun.resources)
+ }
+ if (!lastHuman.attachedDatabases?.length) {
+ lastHuman.attachedDatabases = refsToDatabases(latestRun.resources)
+ }
+ if (!lastHuman.attachedDb) {
+ lastHuman.attachedDb = refsToDatabase(latestRun.resources)
+ }
+ }
+
+ const last = mapped[mapped.length - 1]
+ const live = Boolean(latestRun && isLiveRunStatus(latestRun.status))
+ if (live && last?.role === 'human') {
+ mapped.push({
+ id: `view-${latestRun!.id}`,
+ role: 'view',
+ context: '',
+ order: last.order,
+ thinking: true,
+ runId: latestRun!.id,
+ })
+ } else if (live && last?.role === 'view' && !last.context.trim()) {
+ last.thinking = true
+ if (!last.runId) last.runId = latestRun?.id
+ }
+
+ return mapped
+}
diff --git a/src/features/assistant/adapters/runToTurn.ts b/src/features/assistant/adapters/runToTurn.ts
new file mode 100644
index 0000000..97b1188
--- /dev/null
+++ b/src/features/assistant/adapters/runToTurn.ts
@@ -0,0 +1,30 @@
+import type { ChatMessage } from '../types/chat'
+import type { AgentTurnView } from '../types/viewModels'
+
+export function messagesToTurns(
+ messages: ChatMessage[],
+): Array<{ human: ChatMessage; view?: ChatMessage }> {
+ const result: Array<{ human: ChatMessage; view?: ChatMessage }> = []
+ for (let i = 0; i < messages.length; i += 1) {
+ const msg = messages[i]
+ if (msg.role === 'human') {
+ result.push({
+ human: msg,
+ view: messages[i + 1]?.role === 'view' ? messages[i + 1] : undefined,
+ })
+ }
+ }
+ return result
+}
+
+export function toAgentTurnView(round: { human: ChatMessage; view?: ChatMessage }): AgentTurnView {
+ return {
+ id: round.human.id,
+ userMessage: round.human.context,
+ assistantMessage: round.view?.context,
+ taskPlan: round.view?.taskPlan,
+ attachedFiles: round.human.attachedFiles,
+ isWorking: Boolean(round.view?.thinking),
+ thinkingContent: round.view?.thinking ? '' : undefined,
+ }
+}
diff --git a/src/features/assistant/adapters/stepLabels.test.ts b/src/features/assistant/adapters/stepLabels.test.ts
new file mode 100644
index 0000000..3e15ed1
--- /dev/null
+++ b/src/features/assistant/adapters/stepLabels.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { stepDetailLabel, stepTypeLabel, toolActionName } from './stepLabels'
+
+describe('stepTypeLabel', () => {
+ it('translates a tool slug used as the step title', () => {
+ expect(stepTypeLabel('code_interpreter', 'code_interpreter')).toBe('تحلیل داده')
+ })
+
+ it('translates a known action when the title is empty', () => {
+ expect(stepTypeLabel('load_skill', '')).toBe('بارگذاری مهارت')
+ })
+
+ it('keeps a free-form title that is not a tool slug', () => {
+ expect(stepTypeLabel('code_interpreter', 'جمعبندی فروش')).toBe('جمعبندی فروش')
+ })
+})
+
+describe('toolActionName', () => {
+ it('hides the raw slug when a Persian label exists', () => {
+ expect(toolActionName('code_interpreter')).toBe('')
+ })
+
+ it('keeps unknown action names', () => {
+ expect(toolActionName('custom_tool')).toBe('custom_tool')
+ })
+})
+
+describe('stepDetailLabel', () => {
+ it('translates the ReAct placeholder', () => {
+ expect(stepDetailLabel('Thought/Action/Observation')).toBe('تفکر / اقدام / مشاهده')
+ })
+})
diff --git a/src/features/assistant/adapters/stepLabels.ts b/src/features/assistant/adapters/stepLabels.ts
new file mode 100644
index 0000000..f970fc4
--- /dev/null
+++ b/src/features/assistant/adapters/stepLabels.ts
@@ -0,0 +1,71 @@
+import { t } from '../../../i18n'
+
+const ENGINE_LABELS: Record = {
+ code_interpreter: 'assistant.execution.dataAnalysis',
+ execute_analysis: 'assistant.execution.dataAnalysis',
+ sql_executor: 'assistant.execution.databaseQuery',
+ sql_query: 'assistant.execution.databaseQuery',
+ knowledge_search: 'assistant.execution.knowledgeSearch',
+ load_file: 'assistant.execution.fileInspection',
+ file_reader: 'assistant.execution.fileInspection',
+ load_skill: 'assistant.execution.loadSkill',
+ execute_skill_script_file: 'assistant.execution.executeSkill',
+ html_interpreter: 'assistant.execution.htmlBuild',
+ shell_interpreter: 'assistant.execution.shellCommand',
+ question: 'assistant.execution.askUser',
+}
+
+const CATEGORY_LABELS: Record = {
+ code_interpreter: 'assistant.panel.categoryPython',
+ execute_analysis: 'assistant.panel.categoryPython',
+ execute_skill_script_file: 'assistant.panel.categoryPython',
+ sql_executor: 'assistant.panel.categorySql',
+ sql_query: 'assistant.panel.categorySql',
+ load_file: 'assistant.panel.categoryFile',
+ file_reader: 'assistant.panel.categoryFile',
+ knowledge_search: 'assistant.panel.categoryKnowledge',
+ html_interpreter: 'assistant.panel.categoryHtml',
+ shell_interpreter: 'assistant.panel.categoryShell',
+ load_skill: 'assistant.panel.categorySkill',
+}
+
+const DETAIL_LABELS: Record = {
+ 'Thought/Action/Observation': 'assistant.execution.reactDetail',
+}
+
+function engineLabelKey(value?: string): string | undefined {
+ const key = value?.trim()
+ return key ? ENGINE_LABELS[key] : undefined
+}
+
+export function stepTypeLabel(action?: string, title?: string): string {
+ const actionKey = action?.trim() || ''
+ const titleKey = title?.trim() || ''
+ const mappedTitle = engineLabelKey(titleKey)
+ if (mappedTitle) return t(mappedTitle)
+ if (titleKey && titleKey !== actionKey) return titleKey
+ const mappedAction = engineLabelKey(actionKey)
+ if (mappedAction) return t(mappedAction)
+ if (titleKey) return titleKey
+ if (actionKey) return actionKey
+ return t('assistant.execution.processing')
+}
+
+export function stepCategoryLabel(action?: string): string {
+ if (!action) return t('assistant.panel.categoryTool')
+ const key = CATEGORY_LABELS[action]
+ return key ? t(key) : t('assistant.panel.categoryTool')
+}
+
+export function toolActionName(action?: string): string {
+ const name = action?.trim() || ''
+ if (!name || engineLabelKey(name)) return ''
+ return name
+}
+
+export function stepDetailLabel(detail?: string): string {
+ const text = detail?.trim() || ''
+ if (!text) return ''
+ const key = DETAIL_LABELS[text]
+ return key ? t(key) : text
+}
diff --git a/src/features/assistant/api/chat.api.ts b/src/features/assistant/api/chat.api.ts
new file mode 100644
index 0000000..b5da6e4
--- /dev/null
+++ b/src/features/assistant/api/chat.api.ts
@@ -0,0 +1,77 @@
+import { kamidsoUrl } from '../../../services/api'
+
+export type ResourceRef = {
+ id: string
+ type: 'file' | 'datasource' | 'knowledge'
+ name?: string
+}
+
+export type KamidsoChatRequest = {
+ conversation_id?: string
+ message: string
+ resources?: ResourceRef[]
+ skill_id?: string
+ model_id?: string
+ mode?: 'auto' | 'analysis'
+ parent_run_id?: string
+ temperature?: number
+ max_new_tokens?: number
+ reasoning_mode?: 'fast' | 'medium' | 'deep'
+ report_mode?: boolean
+}
+
+export async function streamChat(
+ body: KamidsoChatRequest,
+ signal: AbortSignal,
+): Promise {
+ const response = await fetch(kamidsoUrl('/chat'), {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
+ body: JSON.stringify(body),
+ signal,
+ })
+ if (!response.ok) {
+ const data = await response.json().catch(() => null)
+ throw new Error((data as { message?: string })?.message || 'ارسال پیام ناموفق بود')
+ }
+ if (!response.body) throw new Error('No response body')
+ return response
+}
+
+export async function replyQuestion(runId: string, questionId: string, reply: string) {
+ if (!questionId) {
+ throw new Error('شناسه سؤال دریافت نشد')
+ }
+ const response = await fetch(kamidsoUrl(`/runs/${runId}/questions/${encodeURIComponent(questionId)}/reply`), {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ reply }),
+ })
+ if (!response.ok) {
+ const data = await response.json().catch(() => null)
+ throw new Error((data as { message?: string; detail?: { message?: string } })?.detail?.message || 'ارسال پاسخ ناموفق بود')
+ }
+}
+
+export async function rejectQuestion(runId: string, questionId: string) {
+ if (!questionId) {
+ throw new Error('شناسه سؤال دریافت نشد')
+ }
+ const response = await fetch(kamidsoUrl(`/runs/${runId}/questions/${encodeURIComponent(questionId)}/reject`), {
+ method: 'POST',
+ credentials: 'include',
+ })
+ if (!response.ok) {
+ const data = await response.json().catch(() => null)
+ throw new Error((data as { detail?: { message?: string } })?.detail?.message || 'رد سؤال ناموفق بود')
+ }
+}
+
+export async function cancelRun(runId: string) {
+ await fetch(kamidsoUrl(`/runs/${runId}/cancel`), {
+ method: 'POST',
+ credentials: 'include',
+ })
+}
diff --git a/src/features/assistant/api/conversations.api.ts b/src/features/assistant/api/conversations.api.ts
new file mode 100644
index 0000000..b047292
--- /dev/null
+++ b/src/features/assistant/api/conversations.api.ts
@@ -0,0 +1,101 @@
+import { apiFetch } from '../../../services/api'
+
+export type ConversationListItem = {
+ id: string
+ conversation_id: string
+ title?: string
+ updated_at?: string
+}
+
+export type ConversationListPage = {
+ items: ConversationListItem[]
+ total: number
+ limit: number
+ offset: number
+}
+
+export async function listConversations(params?: {
+ limit?: number
+ offset?: number
+}): Promise {
+ const query = new URLSearchParams()
+ if (params?.limit != null) query.set('limit', String(params.limit))
+ if (params?.offset != null) query.set('offset', String(params.offset))
+ const suffix = query.toString() ? `?${query}` : ''
+ const raw = await apiFetch<{
+ conversations?: ConversationListItem[]
+ total?: number
+ limit?: number
+ offset?: number
+ }>(`/conversations${suffix}`)
+ const items = raw.conversations ?? []
+ return {
+ items,
+ total: raw.total ?? items.length,
+ limit: raw.limit ?? params?.limit ?? items.length,
+ offset: raw.offset ?? params?.offset ?? 0,
+ }
+}
+
+export async function createConversation(title?: string): Promise {
+ const raw = await apiFetch<{ conversation_id: string }>('/conversations', {
+ method: 'POST',
+ body: JSON.stringify({ title }),
+ })
+ return raw.conversation_id
+}
+
+/** Resolve a server-backed conversation id (never a client-only UUID). */
+export async function ensureConversation(existingId?: string | null): Promise {
+ if (existingId) return existingId
+ return createConversation()
+}
+
+export type ConversationHistoryMessage = {
+ id: string
+ role: string
+ content: string
+ run_id?: string | null
+ resource_refs?: Array<{
+ id?: string
+ resource_id?: string
+ type?: string
+ resource_type?: string
+ name?: string
+ resource_name?: string
+ }>
+}
+
+export type ConversationHistoryResponse = {
+ conversation: { id: string; title?: string }
+ messages: ConversationHistoryMessage[]
+ active_run_id?: string | null
+ latest_run?: {
+ id: string
+ status: string
+ answer?: string
+ steps?: Array<{
+ id: string
+ title: string
+ status: string
+ type?: string
+ }>
+ artifacts?: unknown[]
+ resources?: Array<{
+ id?: string
+ resource_id?: string
+ type?: string
+ resource_type?: string
+ name?: string
+ resource_name?: string
+ }>
+ } | null
+}
+
+export async function getConversationHistory(
+ conversationId: string,
+): Promise {
+ return apiFetch(
+ `/conversations/${encodeURIComponent(conversationId)}`,
+ )
+}
diff --git a/src/features/assistant/api/reports.api.ts b/src/features/assistant/api/reports.api.ts
new file mode 100644
index 0000000..8e22230
--- /dev/null
+++ b/src/features/assistant/api/reports.api.ts
@@ -0,0 +1,32 @@
+import { apiFetch } from '../../../services/api'
+
+export type HtmlShare = {
+ id: string
+ title: string
+ share_path: string
+}
+
+export type SharedHtmlReport = {
+ id: string
+ title: string
+ html: string
+}
+
+export async function createHtmlShare(html: string, title?: string): Promise {
+ const raw = await apiFetch<{ report: HtmlShare }>('/reports/html-shares', {
+ method: 'POST',
+ body: JSON.stringify({ html, title }),
+ })
+ return raw.report
+}
+
+export async function fetchSharedReport(reportId: string): Promise {
+ const raw = await apiFetch<{
+ report: { id: string; title?: string; html?: string; kind?: string }
+ }>(`/share/reports/${encodeURIComponent(reportId)}`)
+ return {
+ id: String(raw.report?.id ?? reportId),
+ title: String(raw.report?.title ?? ''),
+ html: String(raw.report?.html ?? ''),
+ }
+}
diff --git a/src/features/assistant/api/resources.api.ts b/src/features/assistant/api/resources.api.ts
new file mode 100644
index 0000000..4660b69
--- /dev/null
+++ b/src/features/assistant/api/resources.api.ts
@@ -0,0 +1,65 @@
+import { apiFetchEnvelope } from '../../../services/api'
+import type { ResourceOption } from '../types/chat'
+import { listKnowledgeSpaces as listKnowledgeSpaceRecords } from '../../knowledge/api'
+import { listDatasourceRecords, toResourceOption } from '../../databases/api'
+
+export async function listModels(): Promise {
+ try {
+ const data = await apiFetchEnvelope<{ [key: string]: unknown }>('/models')
+ if (Array.isArray(data)) return data as string[]
+ const models = (data as { models?: string[] })?.models
+ if (Array.isArray(models)) return models
+ return []
+ } catch {
+ return []
+ }
+}
+
+export async function listDatasources(): Promise {
+ try {
+ const rows = await listDatasourceRecords()
+ return rows.map(toResourceOption)
+ } catch {
+ return []
+ }
+}
+
+export async function listKnowledgeSpaces(): Promise {
+ try {
+ const spaces = await listKnowledgeSpaceRecords()
+ return spaces.map((space) => ({
+ id: space.id,
+ name: space.name,
+ description: space.description || '',
+ }))
+ } catch {
+ return []
+ }
+}
+
+export async function listSkills(): Promise {
+ try {
+ const data = await apiFetchEnvelope('/skills')
+ const list = Array.isArray(data)
+ ? data
+ : (data as { data?: unknown[]; skills?: unknown[] })?.skills ??
+ (data as { data?: unknown[] })?.data
+ if (!Array.isArray(list)) return []
+ return list.map((item, i) => {
+ const row = item as Record
+ return {
+ id: String(row.id ?? row.name ?? i),
+ name: String(row.name ?? `skill-${i}`),
+ description: String(row.description ?? ''),
+ nameEn: row.name_en ? String(row.name_en) : undefined,
+ category: row.category ? String(row.category) : undefined,
+ categoryId: row.category_id ? String(row.category_id) : undefined,
+ domain: row.domain ? String(row.domain) : undefined,
+ icon: row.icon ? String(row.icon) : undefined,
+ settings: row.settings && typeof row.settings === 'object' ? (row.settings as ResourceOption['settings']) : undefined,
+ }
+ })
+ } catch {
+ return []
+ }
+}
diff --git a/src/features/assistant/api/runs.api.ts b/src/features/assistant/api/runs.api.ts
new file mode 100644
index 0000000..09fb3ae
--- /dev/null
+++ b/src/features/assistant/api/runs.api.ts
@@ -0,0 +1,23 @@
+import { apiFetch, kamidsoUrl } from '../../../api/client'
+import type { KamidsoRun } from '../../../contracts/run'
+
+export async function getRun(runId: string): Promise {
+ return apiFetch(`/runs/${runId}`)
+}
+
+export async function streamRunEvents(
+ runId: string,
+ after: number,
+ signal?: AbortSignal,
+): Promise {
+ const response = await fetch(kamidsoUrl(`/runs/${runId}/events?after=${after}`), {
+ credentials: 'include',
+ headers: { Accept: 'text/event-stream' },
+ signal,
+ })
+ if (!response.ok) {
+ throw new Error('دریافت رویدادهای اجرا ناموفق بود.')
+ }
+ if (!response.body) throw new Error('No response body')
+ return response
+}
diff --git a/src/features/assistant/artifacts/ArtifactRenderer.tsx b/src/features/assistant/artifacts/ArtifactRenderer.tsx
new file mode 100644
index 0000000..2eff7ec
--- /dev/null
+++ b/src/features/assistant/artifacts/ArtifactRenderer.tsx
@@ -0,0 +1,26 @@
+import type { KamidsoArtifact } from '../../../contracts/run'
+import { CodePreview } from './CodePreview'
+import { TablePreview } from './TablePreview'
+import { ChartPreview } from './ChartPreview'
+import { JsonPreview } from './JsonPreview'
+import { HtmlPreview } from '../components/HtmlPreview'
+import { MarkdownAnswer } from '../components/MarkdownAnswer'
+
+export function ArtifactRenderer({ artifact }: { artifact: KamidsoArtifact }) {
+ switch (artifact.type) {
+ case 'code':
+ return
+ case 'table':
+ return
+ case 'chart':
+ return } />
+ case 'html':
+ return
+ case 'markdown':
+ return
+ case 'json':
+ return
+ default:
+ return
+ }
+}
diff --git a/src/features/assistant/artifacts/ChartPreview.tsx b/src/features/assistant/artifacts/ChartPreview.tsx
new file mode 100644
index 0000000..64a8959
--- /dev/null
+++ b/src/features/assistant/artifacts/ChartPreview.tsx
@@ -0,0 +1,246 @@
+import { lazy, Suspense } from 'react'
+import { formatNumber, parseNumeric } from '../report/formatValue'
+
+type SeriesItem = { field: string; label: string }
+
+function normalizeSpec(spec: Record): {
+ chartType: string
+ data: Record[]
+ xKey: string
+ series: SeriesItem[]
+ stacked: boolean
+ showGrid: boolean
+ height?: number
+} {
+ const data = (spec.data as Record[]) || []
+ const xKey = String(spec.x ?? spec.xField ?? 'name')
+ const chartType = String(spec.chartType ?? 'bar')
+ const stacked = spec.stacked === true
+ const showGrid = spec.showGrid !== false
+ const height = typeof spec.height === 'number' ? spec.height : undefined
+
+ let series: SeriesItem[] = []
+ const rawSeries = spec.series
+ if (Array.isArray(rawSeries) && rawSeries.length) {
+ series = rawSeries
+ .map((entry) => {
+ if (typeof entry === 'string') return { field: entry, label: entry }
+ if (!entry || typeof entry !== 'object') return null
+ const obj = entry as Record
+ const field = String(obj.field ?? obj.key ?? '').trim()
+ if (!field) return null
+ return { field, label: String(obj.label ?? field) }
+ })
+ .filter((x): x is SeriesItem => Boolean(x))
+ } else {
+ const yRaw = spec.y ?? spec.yField ?? 'value'
+ if (Array.isArray(yRaw)) {
+ series = yRaw.map((field) => ({ field: String(field), label: String(field) }))
+ } else {
+ series = [{ field: String(yRaw), label: String(yRaw) }]
+ }
+ }
+ if (!series.length) series = [{ field: 'value', label: 'value' }]
+ return { chartType, data, xKey, series, stacked, showGrid, height }
+}
+
+type ChartPreviewProps = {
+ spec: Record
+ variant?: 'code' | 'light'
+ compact?: boolean
+}
+
+const COLORS = ['#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2']
+
+const ChartInner = lazy(async () => {
+ try {
+ const recharts = await import('recharts')
+ const {
+ ResponsiveContainer,
+ BarChart,
+ Bar,
+ LineChart,
+ Line,
+ AreaChart,
+ Area,
+ PieChart,
+ Pie,
+ ScatterChart,
+ Scatter,
+ Cell,
+ XAxis,
+ YAxis,
+ Tooltip,
+ CartesianGrid,
+ Legend,
+ ZAxis,
+ } = recharts
+ return {
+ default: function ChartInner({ spec, variant = 'code', compact = false }: ChartPreviewProps) {
+ const { chartType, data, xKey, series, stacked, showGrid, height: customHeight } =
+ normalizeSpec(spec)
+ if (!data.length) {
+ return (
+
+ برای این بازه زمانی دادهای موجود نیست.
+
+ )
+ }
+ const height = customHeight ?? (compact ? 180 : 280)
+ const light = variant === 'light'
+ const grid = light ? '#e5eaf2' : '#334155'
+ const tick = { fill: light ? '#64748b' : '#cbd5e1', fontSize: 11 }
+ const showLegend = series.length > 1 || spec.legend === true
+ const tooltip = {
+ contentStyle: light
+ ? {
+ borderRadius: 12,
+ border: '1px solid #e5eaf2',
+ background: '#fff',
+ fontSize: 12,
+ }
+ : undefined,
+ formatter: (value: unknown, name: unknown) => {
+ const numeric = parseNumeric(value)
+ const formatted = numeric == null ? String(value ?? '') : formatNumber(numeric)
+ return [formatted, String(name ?? '')]
+ },
+ }
+
+ if (chartType === 'pie' || chartType === 'donut') {
+ const yKey = series[0].field
+ const inner = chartType === 'donut' ? (compact ? 36 : 48) : 0
+ return (
+
+
+
+ {data.map((_, index) => (
+ |
+ ))}
+
+
+ {showLegend ? : null}
+
+
+ )
+ }
+
+ if (chartType === 'scatter') {
+ const xField = series[0]?.field || xKey
+ const yField = series[1]?.field || series[0]?.field || 'value'
+ return (
+
+
+ {showGrid ? : null}
+
+
+
+
+
+
+
+ )
+ }
+
+ if (chartType === 'area') {
+ return (
+
+
+ {showGrid ? : null}
+
+
+
+ {showLegend ? : null}
+ {series.map((s, index) => (
+
+ ))}
+
+
+ )
+ }
+
+ if (chartType === 'line') {
+ return (
+
+
+ {showGrid ? : null}
+
+
+
+ {showLegend ? : null}
+ {series.map((s, index) => (
+
+ ))}
+
+
+ )
+ }
+
+ return (
+
+
+ {showGrid ? : null}
+
+
+
+ {showLegend ? : null}
+ {series.map((s, index) => (
+
+ ))}
+
+
+ )
+ },
+ }
+ } catch {
+ return {
+ default: function Fallback({ spec }: ChartPreviewProps) {
+ return (
+
+ {JSON.stringify(spec, null, 2)}
+
+ )
+ },
+ }
+ }
+})
+
+export function ChartPreview({ spec, variant = 'code', compact = false }: ChartPreviewProps) {
+ return (
+ بارگذاری نمودار…}>
+
+
+ )
+}
diff --git a/src/features/assistant/artifacts/CodePreview.tsx b/src/features/assistant/artifacts/CodePreview.tsx
new file mode 100644
index 0000000..99adbf9
--- /dev/null
+++ b/src/features/assistant/artifacts/CodePreview.tsx
@@ -0,0 +1 @@
+export { CodeBlock as CodePreview } from '../components/CodeBlock'
diff --git a/src/features/assistant/artifacts/FileDownloadCard.tsx b/src/features/assistant/artifacts/FileDownloadCard.tsx
new file mode 100644
index 0000000..25f4851
--- /dev/null
+++ b/src/features/assistant/artifacts/FileDownloadCard.tsx
@@ -0,0 +1,57 @@
+import { Download, FileSpreadsheet } from 'lucide-react'
+import { t } from '../../../i18n'
+import { resolveApiUrl } from '../../../services/apiBase'
+
+export type DownloadableFile = {
+ url: string
+ name: string
+}
+
+/** Normalize file chunk / artifact content into a downloadable link. */
+export function parseDownloadableFile(content: unknown): DownloadableFile | null {
+ if (content && typeof content === 'object') {
+ const obj = content as Record
+ const url = typeof obj.url === 'string' ? obj.url.trim() : ''
+ if (!url) return null
+ const name =
+ (typeof obj.name === 'string' && obj.name.trim()) ||
+ url.split('/').pop() ||
+ 'download'
+ return { url: resolveApiUrl(url), name: String(name) }
+ }
+ if (typeof content === 'string' && content.trim()) {
+ const url = content.trim()
+ if (!url.startsWith('/files/') && !url.startsWith('http')) return null
+ return { url: resolveApiUrl(url), name: url.split('/').pop() || 'download' }
+ }
+ return null
+}
+
+export function FileDownloadCard({
+ file,
+ compact,
+}: {
+ file: DownloadableFile
+ compact?: boolean
+}) {
+ return (
+
+
+
+
+
+
{file.name}
+
{t('assistant.panel.downloadFile')}
+
+
+
+ )
+}
diff --git a/src/features/assistant/artifacts/JsonPreview.tsx b/src/features/assistant/artifacts/JsonPreview.tsx
new file mode 100644
index 0000000..fdee19b
--- /dev/null
+++ b/src/features/assistant/artifacts/JsonPreview.tsx
@@ -0,0 +1,7 @@
+export function JsonPreview({ data }: { data: unknown }) {
+ return (
+
+ {typeof data === 'string' ? data : JSON.stringify(data, null, 2)}
+
+ )
+}
diff --git a/src/features/assistant/artifacts/TablePreview.tsx b/src/features/assistant/artifacts/TablePreview.tsx
new file mode 100644
index 0000000..7ffc1dd
--- /dev/null
+++ b/src/features/assistant/artifacts/TablePreview.tsx
@@ -0,0 +1,61 @@
+import { normalizeTableContent } from './normalizeTable'
+
+export function TablePreview({
+ table,
+}: {
+ table:
+ | { columns?: string[] | Array<{ title?: string; dataIndex?: string; key?: string }>; rows?: unknown[] }
+ | Record
+}) {
+ const normalized = normalizeTableContent(table)
+ if (!normalized) {
+ return (
+
+ {JSON.stringify(table, null, 2)}
+
+ )
+ }
+
+ const { columns, rows } = normalized
+
+ return (
+
+
+
+
+ {columns.map((col) => (
+ |
+ {col.title}
+ |
+ ))}
+
+
+
+ {rows.slice(0, 100).map((row, rowIndex) => (
+
+ {columns.map((col) => (
+ |
+ {formatCell(row[col.dataIndex])}
+ |
+ ))}
+
+ ))}
+
+
+ {rows.length > 100 ? (
+
+ {rows.length - 100}+ ردیف دیگر
+
+ ) : null}
+
+ )
+}
+
+function formatCell(value: unknown): string {
+ if (value == null || (typeof value === 'number' && Number.isNaN(value))) return '—'
+ if (typeof value === 'object') return JSON.stringify(value)
+ return String(value)
+}
diff --git a/src/features/assistant/artifacts/normalizeTable.test.ts b/src/features/assistant/artifacts/normalizeTable.test.ts
new file mode 100644
index 0000000..12b6aaa
--- /dev/null
+++ b/src/features/assistant/artifacts/normalizeTable.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { normalizeTableContent } from './normalizeTable'
+
+describe('normalizeTableContent', () => {
+ it('handles ant-design style columns and record rows', () => {
+ const table = normalizeTableContent({
+ columns: [{ title: 'Name', dataIndex: 'name', key: 'name' }],
+ rows: [{ name: 'Ali' }, { name: 'Sara' }],
+ })
+ expect(table?.columns[0].dataIndex).toBe('name')
+ expect(table?.rows[0].name).toBe('Ali')
+ })
+
+ it('handles string columns and array rows', () => {
+ const table = normalizeTableContent({
+ columns: ['a', 'b'],
+ rows: [['1', '2']],
+ })
+ expect(table?.rows[0].a).toBe('1')
+ expect(table?.rows[0].b).toBe('2')
+ })
+})
diff --git a/src/features/assistant/artifacts/normalizeTable.ts b/src/features/assistant/artifacts/normalizeTable.ts
new file mode 100644
index 0000000..affc32b
--- /dev/null
+++ b/src/features/assistant/artifacts/normalizeTable.ts
@@ -0,0 +1,51 @@
+export type NormalizedTableColumn = {
+ key: string
+ title: string
+ dataIndex: string
+}
+
+export type NormalizedTable = {
+ columns: NormalizedTableColumn[]
+ rows: Record[]
+}
+
+/** Normalize DBGPT table payloads (string columns or Ant Design-style objects). */
+export function normalizeTableContent(content: unknown): NormalizedTable | null {
+ if (!content || typeof content !== 'object') return null
+ const raw = content as { columns?: unknown[]; headers?: unknown[]; rows?: unknown[] }
+ const rawColumns = raw.columns ?? raw.headers ?? []
+ const rawRows = raw.rows ?? []
+ if (!rawColumns.length) return null
+
+ const columns: NormalizedTableColumn[] = rawColumns.map((col, index) => {
+ if (typeof col === 'string') {
+ return { key: col, title: col, dataIndex: col }
+ }
+ if (col && typeof col === 'object') {
+ const item = col as { title?: string; dataIndex?: string; key?: string }
+ const dataIndex = String(item.dataIndex ?? item.key ?? item.title ?? index)
+ return {
+ key: String(item.key ?? dataIndex),
+ title: String(item.title ?? dataIndex),
+ dataIndex,
+ }
+ }
+ return { key: String(index), title: String(col), dataIndex: String(index) }
+ })
+
+ const rows: Record[] = rawRows.map((row, rowIndex) => {
+ if (Array.isArray(row)) {
+ const record: Record = { __rowKey: rowIndex }
+ columns.forEach((col, colIndex) => {
+ record[col.dataIndex] = row[colIndex]
+ })
+ return record
+ }
+ if (row && typeof row === 'object') {
+ return { ...(row as Record), __rowKey: rowIndex }
+ }
+ return { __rowKey: rowIndex, value: row }
+ })
+
+ return { columns, rows }
+}
diff --git a/src/features/assistant/components/CodeBlock.tsx b/src/features/assistant/components/CodeBlock.tsx
new file mode 100644
index 0000000..ff04aff
--- /dev/null
+++ b/src/features/assistant/components/CodeBlock.tsx
@@ -0,0 +1,94 @@
+import { Check, Copy, X } from 'lucide-react'
+import { useState } from 'react'
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
+import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
+import { t } from '../../../i18n'
+
+export function CodeBlock({
+ code,
+ language = 'text',
+ variant = 'light',
+ maxHeight,
+ label,
+ terminal,
+}: {
+ code: string
+ language?: string
+ variant?: 'light' | 'execution'
+ maxHeight?: number
+ label?: string
+ terminal?: { content: string; success?: boolean }
+}) {
+ const [copied, setCopied] = useState(false)
+ const isExecution = variant === 'execution'
+
+ async function copy() {
+ await navigator.clipboard.writeText(code)
+ setCopied(true)
+ window.setTimeout(() => setCopied(false), 1500)
+ }
+
+ const style = isExecution ? oneDark : oneLight
+ const bg = isExecution ? '#0f172a' : '#fff'
+
+ return (
+
+
+
+ {label || language}
+
+
+
+
+ {code}
+
+ {terminal ? (
+ <>
+
+
+ {t('assistant.panel.terminal')}
+ {terminal.success === false ? (
+
+ ) : (
+
+ )}
+
+
+ {terminal.content}
+
+ >
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/components/Composer.tsx b/src/features/assistant/components/Composer.tsx
new file mode 100644
index 0000000..57503df
--- /dev/null
+++ b/src/features/assistant/components/Composer.tsx
@@ -0,0 +1,87 @@
+import { Loader2, Send, Square } from 'lucide-react'
+import { useRef, type ReactNode } from 'react'
+import { Button } from '../../../components/Button'
+import AttachmentRail, { AttachmentRailAddButton } from '../session-files/AttachmentRail'
+import type { DraftFile, LegacyServerFile } from '../session-files'
+import { DATA_FILE_ACCEPT } from '../session-files/fileKind'
+
+export function Composer({
+ query,
+ onQueryChange,
+ onSend,
+ onStop,
+ loading,
+ drafts,
+ legacyFiles,
+ onRemoveFile,
+ onRetryFile,
+ onAddFiles,
+ resourceBar,
+}: {
+ query: string
+ onQueryChange: (value: string) => void
+ onSend: () => void
+ onStop: () => void
+ loading: boolean
+ drafts: readonly DraftFile[]
+ legacyFiles?: readonly LegacyServerFile[]
+ onRemoveFile: (clientId: string) => void
+ onRetryFile: (clientId: string) => void
+ onAddFiles: (files: FileList | File[]) => void
+ resourceBar?: ReactNode
+}) {
+ const fileInputRef = useRef(null)
+
+ return (
+
+ )
+}
diff --git a/src/features/assistant/components/ConversationRail.tsx b/src/features/assistant/components/ConversationRail.tsx
new file mode 100644
index 0000000..4d94b14
--- /dev/null
+++ b/src/features/assistant/components/ConversationRail.tsx
@@ -0,0 +1,75 @@
+import { MessageSquarePlus, PanelLeftClose } from 'lucide-react'
+import type { ConversationListItem } from '../api/conversations.api'
+
+export function ConversationRail({
+ items,
+ activeId,
+ onSelect,
+ onNew,
+ collapsed,
+ onToggle,
+}: {
+ items: ConversationListItem[]
+ activeId?: string | null
+ onSelect: (id: string) => void
+ onNew: () => void
+ collapsed: boolean
+ onToggle: () => void
+}) {
+ if (collapsed) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/features/assistant/components/HtmlPreview.test.tsx b/src/features/assistant/components/HtmlPreview.test.tsx
new file mode 100644
index 0000000..903af49
--- /dev/null
+++ b/src/features/assistant/components/HtmlPreview.test.tsx
@@ -0,0 +1,65 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createHtmlShare } from '../api/reports.api'
+import { downloadReportPdf } from '../report/downloadReportPdf'
+import { HtmlPreview } from './HtmlPreview'
+
+vi.mock('../report/downloadReportPdf', async () => {
+ const actual = await vi.importActual(
+ '../report/downloadReportPdf',
+ )
+ return { ...actual, downloadReportPdf: vi.fn() }
+})
+
+vi.mock('../api/reports.api', () => ({
+ createHtmlShare: vi.fn(async () => ({
+ id: 'rpt_abc',
+ title: 'گزارش',
+ share_path: '/share/reports/rpt_abc',
+ })),
+}))
+
+const sample = "گزارش
"
+
+describe('HtmlPreview', () => {
+ beforeEach(() => {
+ vi.mocked(downloadReportPdf).mockClear()
+ vi.mocked(createHtmlShare).mockClear()
+ })
+
+ it('opens the report full screen and closes it', async () => {
+ const user = userEvent.setup()
+ render()
+
+ expect(screen.getByTitle('html-preview')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'تمامصفحه' }))
+ expect(screen.getByRole('dialog', { name: 'گزارش تحلیل' })).toBeInTheDocument()
+ expect(screen.getByTitle('html-preview-full')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'بستن' }))
+ expect(screen.queryByRole('dialog', { name: 'گزارش تحلیل' })).not.toBeInTheDocument()
+ })
+
+ it('downloads the report as a print-ready PDF', async () => {
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByRole('button', { name: 'دانلود PDF' }))
+ expect(downloadReportPdf).toHaveBeenCalledWith(sample, 'گزارش تحلیل')
+ })
+
+ it('creates a public share link and copies it', async () => {
+ const user = userEvent.setup()
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ vi.spyOn(navigator.clipboard, 'writeText').mockImplementation(writeText)
+ render()
+
+ await user.click(screen.getByRole('button', { name: 'اشتراکگذاری' }))
+ expect(createHtmlShare).toHaveBeenCalledWith(sample, 'گزارش تحلیل')
+ expect(await screen.findByText(/\/share\/reports\/rpt_abc$/)).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'کپی لینک' }))
+ expect(writeText).toHaveBeenCalledWith(expect.stringMatching(/\/share\/reports\/rpt_abc$/))
+ expect(screen.getByRole('button', { name: 'لینک کپی شد' })).toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/components/HtmlPreview.tsx b/src/features/assistant/components/HtmlPreview.tsx
new file mode 100644
index 0000000..7abf13b
--- /dev/null
+++ b/src/features/assistant/components/HtmlPreview.tsx
@@ -0,0 +1,276 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import { Check, Copy, Download, ExternalLink, FileText, Maximize2, Share2, X } from 'lucide-react'
+import { t } from '../../../i18n'
+import { useBodyScrollLock, useFocusTrap } from '../../../components/ui/useFocusTrap'
+import { createHtmlShare } from '../api/reports.api'
+import { downloadReportPdf, reportTitleFromHtml } from '../report/downloadReportPdf'
+
+function ReportFrame({
+ html,
+ title,
+ className,
+}: {
+ html: string
+ title: string
+ className: string
+}) {
+ return
+}
+
+type ShareAnchor = 'inline' | 'full'
+
+type ShareBox = { top: number; left: number; width: number }
+
+const actionClass =
+ 'inline-flex h-8 items-center gap-1.5 rounded-lg px-2 text-[12px] font-medium text-ink-muted transition hover:bg-black/5 hover:text-ink'
+
+function shareBoxFor(button: HTMLElement): ShareBox {
+ const rect = button.getBoundingClientRect()
+ const width = Math.min(352, window.innerWidth - 16)
+ const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8))
+ return { top: rect.bottom + 8, left, width }
+}
+
+export function HtmlPreview({ html }: { html: string }) {
+ const [expanded, setExpanded] = useState(false)
+ const [shareAnchor, setShareAnchor] = useState(null)
+ const [shareBox, setShareBox] = useState(null)
+ const shareButtonRef = useRef(null)
+ const [shareUrl, setShareUrl] = useState(null)
+ const [sharing, setSharing] = useState(false)
+ const [shareError, setShareError] = useState(false)
+ const [copied, setCopied] = useState(false)
+ const dialogRef = useRef(null)
+ const htmlRef = useRef(html)
+ const shareCache = useRef<{ html: string; url: string } | null>(null)
+ htmlRef.current = html
+
+ const expandLabel = t('assistant.report.expand')
+ const closeLabel = t('assistant.report.close')
+ const title = t('assistant.report.title')
+ const downloadLabel = t('assistant.report.downloadPdf')
+ const shareLabel = t('assistant.report.share')
+ const documentTitle = reportTitleFromHtml(html, title)
+
+ useFocusTrap(dialogRef, expanded)
+ useBodyScrollLock(expanded)
+
+ useEffect(() => {
+ if (!expanded && !shareAnchor) return
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key !== 'Escape') return
+ if (shareAnchor) {
+ setShareAnchor(null)
+ return
+ }
+ setExpanded(false)
+ }
+ document.addEventListener('keydown', onKeyDown)
+ return () => document.removeEventListener('keydown', onKeyDown)
+ }, [expanded, shareAnchor])
+
+ useEffect(() => {
+ if (!shareAnchor) return
+ function onPointerDown(event: MouseEvent) {
+ const target = event.target
+ if (!(target instanceof Element)) return
+ if (target.closest('[data-report-share]')) return
+ setShareAnchor(null)
+ }
+ function onPlace() {
+ const button = shareButtonRef.current
+ if (button) setShareBox(shareBoxFor(button))
+ }
+ onPlace()
+ document.addEventListener('mousedown', onPointerDown)
+ window.addEventListener('resize', onPlace)
+ window.addEventListener('scroll', onPlace, true)
+ return () => {
+ document.removeEventListener('mousedown', onPointerDown)
+ window.removeEventListener('resize', onPlace)
+ window.removeEventListener('scroll', onPlace, true)
+ }
+ }, [shareAnchor])
+
+ async function openShare(anchor: ShareAnchor, button: HTMLElement) {
+ shareButtonRef.current = button
+ setShareBox(shareBoxFor(button))
+ setShareAnchor(anchor)
+ setCopied(false)
+ setShareError(false)
+ const cached = shareCache.current
+ if (cached?.html === html) {
+ setShareUrl(cached.url)
+ return
+ }
+ const requested = html
+ setSharing(true)
+ setShareUrl(null)
+ try {
+ const created = await createHtmlShare(requested, reportTitleFromHtml(requested, title))
+ if (htmlRef.current !== requested) return
+ const url = `${window.location.origin}${created.share_path}`
+ shareCache.current = { html: requested, url }
+ setShareUrl(url)
+ } catch {
+ if (htmlRef.current === requested) setShareError(true)
+ } finally {
+ setSharing(false)
+ }
+ }
+
+ async function copyLink() {
+ if (!shareUrl) return
+ try {
+ await navigator.clipboard.writeText(shareUrl)
+ setCopied(true)
+ } catch {
+ setCopied(false)
+ }
+ }
+
+ function download() {
+ downloadReportPdf(html, documentTitle)
+ }
+
+ function toolbar(anchor: ShareAnchor, extra?: ReactNode) {
+ const shareOpen = shareAnchor === anchor
+ return (
+
+
+
+ {extra}
+
+ )
+ }
+
+ const sharePanel =
+ shareAnchor && shareBox
+ ? createPortal(
+
+ {sharing ?
{t('assistant.report.sharing')}
: null}
+ {shareError ?
{t('assistant.report.shareFailed')}
: null}
+ {shareUrl ? (
+
+
+ {shareUrl}
+
+
+
+
+
+ {t('assistant.report.openPage')}
+
+
+
+ ) : null}
+
,
+ document.body,
+ )
+ : null
+
+ return (
+ <>
+ {sharePanel}
+
+
+
+
+
+
+ {documentTitle}
+
+ {toolbar(
+ 'inline',
+
,
+ )}
+
+
+
+ {expanded
+ ? createPortal(
+
+
+
+
{documentTitle}
+ {toolbar(
+ 'full',
+ ,
+ )}
+
+
+
+
,
+ document.body,
+ )
+ : null}
+ >
+ )
+}
diff --git a/src/features/assistant/components/ManusLeftPanel.tsx b/src/features/assistant/components/ManusLeftPanel.tsx
new file mode 100644
index 0000000..b2ac449
--- /dev/null
+++ b/src/features/assistant/components/ManusLeftPanel.tsx
@@ -0,0 +1,54 @@
+import AttachmentMessageCards from '../session-files/AttachmentMessageCards'
+import type { SessionFileSnapshot } from '../session-files'
+import type { ChatMessage, ExecutionStep } from '../types/chat'
+import { MarkdownAnswer } from './MarkdownAnswer'
+import { StepTimeline } from './StepTimeline'
+import { TaskPlanCard } from './TaskPlanCard'
+
+export function ManusLeftPanel({
+ rounds,
+ activeViewId,
+ steps,
+ activeStepId,
+ onStepClick,
+ onPreviewFile,
+}: {
+ rounds: Array<{ human: ChatMessage; view?: ChatMessage }>
+ activeViewId?: string | null
+ steps: ExecutionStep[]
+ activeStepId?: string | null
+ onStepClick?: (stepId: string) => void
+ onPreviewFile?: (file: SessionFileSnapshot) => void
+}) {
+ return (
+
+ {rounds.map(({ human, view }) => {
+ const isActive = view?.id === activeViewId
+ return (
+
+ شما
+ {human.context}
+ {human.attachedFiles?.length ? (
+
+ ) : null}
+
+ {view ? (
+
+
دستیار
+ {view.taskPlan?.length ?
: null}
+ {isActive && steps.length ? (
+
+ ) : null}
+ {view.thinking ? (
+
در حال پردازش…
+ ) : view.context ? (
+
+ ) : null}
+
+ ) : null}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/features/assistant/components/ManusRightPanel.tsx b/src/features/assistant/components/ManusRightPanel.tsx
new file mode 100644
index 0000000..708baa9
--- /dev/null
+++ b/src/features/assistant/components/ManusRightPanel.tsx
@@ -0,0 +1,123 @@
+import { useMemo, useState } from 'react'
+import type { ExecutionOutput, ExecutionStep } from '../types/chat'
+import { CodeBlock } from './CodeBlock'
+import { HtmlPreview } from './HtmlPreview'
+import { MarkdownAnswer } from './MarkdownAnswer'
+
+type Tab = 'execution' | 'summary'
+
+export function ManusRightPanel({
+ steps,
+ outputs,
+ activeStepId,
+ summary,
+ loading,
+}: {
+ steps: ExecutionStep[]
+ outputs: Record
+ activeStepId?: string | null
+ summary?: string
+ loading?: boolean
+}) {
+ const [tab, setTab] = useState('execution')
+ const stepId = activeStepId || steps[steps.length - 1]?.id
+ const stepOutputs = stepId ? outputs[stepId] || [] : []
+
+ const rendered = useMemo(() => {
+ return stepOutputs.map((output, index) => {
+ if (output.output_type === 'code') {
+ return
+ }
+ if (output.output_type === 'html') {
+ return
+ }
+ if (output.output_type === 'markdown' || output.output_type === 'text') {
+ return
+ }
+ if (output.output_type === 'table' && output.content && typeof output.content === 'object') {
+ const table = output.content as { columns?: string[]; rows?: unknown[][] }
+ const columns = table.columns || []
+ const rows = table.rows || []
+ return (
+
+
+
+
+ {columns.map((col) => (
+ |
+ {col}
+ |
+ ))}
+
+
+
+ {rows.slice(0, 50).map((row, ri) => (
+
+ {columns.map((_, ci) => (
+ |
+ {String(row[ci] ?? '')}
+ |
+ ))}
+
+ ))}
+
+
+
+ )
+ }
+ if (output.output_type === 'image') {
+ const src = String(output.content)
+ return (
+
+ )
+ }
+ if (output.output_type === 'error') {
+ return (
+
+ {String(output.content)}
+
+ )
+ }
+ return (
+
+ {typeof output.content === 'string' ? output.content : JSON.stringify(output.content, null, 2)}
+
+ )
+ })
+ }, [stepOutputs])
+
+ return (
+
+
+
+
+
+
+ {loading && tab === 'execution' && !rendered.length ? (
+
در حال اجرای ابزار…
+ ) : null}
+ {tab === 'execution' ? (
+ rendered.length ?
{rendered}
: (
+
خروجی مرحلهٔ فعال اینجا نمایش داده میشود.
+ )
+ ) : summary ? (
+
+ ) : (
+
پس از اتمام، پاسخ نهایی اینجا ظاهر میشود.
+ )}
+
+
+ )
+}
diff --git a/src/features/assistant/components/MarkdownAnswer.test.tsx b/src/features/assistant/components/MarkdownAnswer.test.tsx
new file mode 100644
index 0000000..b0c99e5
--- /dev/null
+++ b/src/features/assistant/components/MarkdownAnswer.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { MarkdownAnswer } from './MarkdownAnswer'
+
+const tableMarkdown = `
+| گروه ریسک | تعداد کاربران | حجم تاریخی کل (تومان) |
+| --- | --- | --- |
+| ریسک بالا | ۸۳ | ۱,۳۵۳,۶۵۷,۸۵۱,۱۳۴ |
+| ریسک متوسط | ۲۹۸ | ۱۲۳ |
+`
+
+describe('MarkdownAnswer tables', () => {
+ it('keeps markdown tables on a real row/column grid', () => {
+ render()
+
+ expect(screen.getByRole('columnheader', { name: 'گروه ریسک' })).toBeInTheDocument()
+ expect(screen.getByRole('columnheader', { name: 'تعداد کاربران' })).toBeInTheDocument()
+ expect(screen.getByRole('columnheader', { name: 'حجم تاریخی کل (تومان)' })).toBeInTheDocument()
+ expect(screen.getByRole('cell', { name: 'ریسک بالا' })).toBeInTheDocument()
+ expect(screen.getByRole('cell', { name: '۸۳' })).toBeInTheDocument()
+ expect(screen.getAllByRole('row')).toHaveLength(3)
+ })
+})
diff --git a/src/features/assistant/components/MarkdownAnswer.tsx b/src/features/assistant/components/MarkdownAnswer.tsx
new file mode 100644
index 0000000..fda32ec
--- /dev/null
+++ b/src/features/assistant/components/MarkdownAnswer.tsx
@@ -0,0 +1,38 @@
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import rehypeKatex from 'rehype-katex'
+import 'katex/dist/katex.min.css'
+import { resolveApiUrl } from '../../../services/apiBase'
+import { CodeBlock } from './CodeBlock'
+import { markdownTableComponents } from './markdownTable'
+
+export function MarkdownAnswer({ content }: { content: string }) {
+ if (!content) return null
+ return (
+
+
+ },
+ code({ className, children, ...props }) {
+ const match = /language-(\w+)/.exec(className || '')
+ const code = String(children).replace(/\n$/, '')
+ if (match) return
+ return (
+
+ {children}
+
+ )
+ },
+ }}
+ >
+ {content}
+
+
+ )
+}
diff --git a/src/features/assistant/components/QuestionDialog.tsx b/src/features/assistant/components/QuestionDialog.tsx
new file mode 100644
index 0000000..39bfbb5
--- /dev/null
+++ b/src/features/assistant/components/QuestionDialog.tsx
@@ -0,0 +1,34 @@
+import { Button } from '../../../components/Button'
+import type { PendingQuestion } from '../types/chat'
+
+export function QuestionDialog({
+ question,
+ onReply,
+ onReject,
+}: {
+ question: PendingQuestion
+ onReply: (text: string) => void
+ onReject: () => void
+}) {
+ return (
+
+
سؤال از طرف دستیار
+
{question.question}
+ {question.options?.length ? (
+
+ {question.options.map((opt) => (
+
+ ))}
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
diff --git a/src/features/assistant/components/StepTimeline.tsx b/src/features/assistant/components/StepTimeline.tsx
new file mode 100644
index 0000000..6df87be
--- /dev/null
+++ b/src/features/assistant/components/StepTimeline.tsx
@@ -0,0 +1,62 @@
+import { ChevronDown, ChevronUp } from 'lucide-react'
+import { useState } from 'react'
+import type { ExecutionStep } from '../types/chat'
+import { ManusStepCard } from '../execution/ManusStepCard'
+import { t } from '../../../i18n'
+
+export function StepTimeline({
+ steps,
+ activeStepId,
+ onStepClick,
+ defaultCollapsed = false,
+}: {
+ steps: ExecutionStep[]
+ activeStepId?: string | null
+ onStepClick?: (stepId: string) => void
+ defaultCollapsed?: boolean
+}) {
+ const [collapsed, setCollapsed] = useState(defaultCollapsed)
+ if (!steps.length) return null
+
+ const runningCount = steps.filter((s) => s.status === 'running').length
+ const doneCount = steps.filter((s) => s.status === 'done' || s.status === 'completed').length
+
+ return (
+
+
+ {!collapsed ? (
+
+ {steps.map((step, index) => {
+ const isLast = index === steps.length - 1
+ return (
+ -
+ {!isLast ? (
+
+ ) : null}
+ onStepClick?.(step.id)}
+ />
+
+ )
+ })}
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/components/TaskPlanCard.tsx b/src/features/assistant/components/TaskPlanCard.tsx
new file mode 100644
index 0000000..f68c0a1
--- /dev/null
+++ b/src/features/assistant/components/TaskPlanCard.tsx
@@ -0,0 +1,76 @@
+import { CheckCircle2, Circle, Loader2, MinusCircle } from 'lucide-react'
+import { useState } from 'react'
+import type { TaskItem } from '../types/chat'
+
+function StatusIcon({ status }: { status: TaskItem['status'] }) {
+ if (status === 'completed') return
+ if (status === 'in_progress') return
+ if (status === 'cancelled') return
+ return
+}
+
+export function visibleTasks(tasks: TaskItem[]): TaskItem[] {
+ return tasks.filter((task) => task.status !== 'cancelled')
+}
+
+export function TaskPlanList({
+ tasks,
+ compact = false,
+}: {
+ tasks: TaskItem[]
+ compact?: boolean
+}) {
+ const items = visibleTasks(tasks)
+ if (!items.length) return null
+
+ return (
+
+ {items.map((task, index) => {
+ const active = task.status === 'in_progress'
+ const done = task.status === 'completed'
+ return (
+ -
+
+
+
+
+ {task.content}
+
+
+ )
+ })}
+
+ )
+}
+
+export function TaskPlanCard({ tasks }: { tasks: TaskItem[] }) {
+ const [collapsed, setCollapsed] = useState(false)
+ const items = visibleTasks(tasks)
+ if (!items.length) return null
+ const done = items.filter((task) => task.status === 'completed').length
+
+ return (
+
+
+ {!collapsed ? (
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/components/markdownTable.tsx b/src/features/assistant/components/markdownTable.tsx
new file mode 100644
index 0000000..a5a34e2
--- /dev/null
+++ b/src/features/assistant/components/markdownTable.tsx
@@ -0,0 +1,80 @@
+import { isValidElement, type ReactNode } from 'react'
+import { toLatinDigits } from '../report/formatValue'
+
+function cellText(node: ReactNode): string {
+ if (node == null || typeof node === 'boolean') return ''
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
+ if (Array.isArray(node)) return node.map(cellText).join('')
+ if (isValidElement<{ children?: ReactNode }>(node)) return cellText(node.props.children)
+ return ''
+}
+
+function isNumericCell(text: string): boolean {
+ const value = toLatinDigits(text).trim()
+ if (!value || value === '—' || value === '-') return false
+ return /^[+\-−]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:\s*[%٪])?(?:\s*[\((][^))]+[\))])?$/.test(
+ value,
+ )
+}
+
+function deltaTone(text: string): 'up' | 'down' | null {
+ if (!/[%٪]|رشد|افزایش|کاهش|افت/.test(text)) return null
+ if (/[+]/.test(text) || /رشد|افزایش/.test(text)) return 'up'
+ if (/[−-]/.test(toLatinDigits(text)) || /کاهش|افت/.test(text)) return 'down'
+ return null
+}
+
+const cellBorder = 'border-s border-line first:border-s-0'
+
+function tdClassName(children: ReactNode): string {
+ const text = cellText(children).trim()
+ const numeric = isNumericCell(text)
+ if (!numeric) return `${cellBorder} whitespace-nowrap px-3.5 py-2.5 align-middle text-ink`
+
+ const tone = deltaTone(text)
+ const toneClass =
+ tone === 'up' ? 'text-success' : tone === 'down' ? 'text-danger' : 'text-ink'
+
+ return `${cellBorder} ltr-island whitespace-nowrap px-3.5 py-2.5 align-middle font-medium tabular-nums ${toneClass}`
+}
+
+export const markdownTableComponents = {
+ table({ children }: { children?: ReactNode }) {
+ return (
+
+ )
+ },
+ thead({ children }: { children?: ReactNode }) {
+ return {children}
+ },
+ tbody({ children }: { children?: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+ },
+ tr({ children }: { children?: ReactNode }) {
+ return {children}
+ },
+ th({ children }: { children?: ReactNode }) {
+ const text = cellText(children).trim()
+ const numeric = isNumericCell(text)
+ return (
+
+ {children}
+ |
+ )
+ },
+ td({ children }: { children?: ReactNode }) {
+ return {children} |
+ },
+}
diff --git a/src/features/assistant/execution/CodeExecutionView.tsx b/src/features/assistant/execution/CodeExecutionView.tsx
new file mode 100644
index 0000000..84e6bf9
--- /dev/null
+++ b/src/features/assistant/execution/CodeExecutionView.tsx
@@ -0,0 +1,168 @@
+import { lazy, Suspense, useState, type ReactNode } from 'react'
+import type { ExecutionOutput } from '../types/chat'
+import { CodeBlock } from '../components/CodeBlock'
+import { FileDownloadCard, parseDownloadableFile } from '../artifacts/FileDownloadCard'
+import { stripCodeFences } from './groupOutputs'
+import { resolveApiUrl } from '../../../services/apiBase'
+import { t } from '../../../i18n'
+
+const TablePreview = lazy(() => import('../artifacts/TablePreview').then((m) => ({ default: m.TablePreview })))
+const ChartPreview = lazy(() => import('../artifacts/ChartPreview').then((m) => ({ default: m.ChartPreview })))
+
+export function CodeExecutionView({
+ codes,
+ results,
+ images,
+ files = [],
+}: {
+ codes: ExecutionOutput[]
+ results: ExecutionOutput[]
+ images: ExecutionOutput[]
+ files?: ExecutionOutput[]
+}) {
+ const downloadable = files
+ .map((item) => parseDownloadableFile(item.content))
+ .filter((item): item is NonNullable => Boolean(item))
+ const [activeCodeIndex, setActiveCodeIndex] = useState(0)
+ const [activeView, setActiveView] = useState<'code' | 'chart'>(images.length > 0 ? 'chart' : 'code')
+
+ const codeBlocks = codes.map((c) => stripCodeFences(String(c.content)))
+ const activeCode = codeBlocks[activeCodeIndex] ?? codeBlocks[0] ?? ''
+ const textResults = results.filter((r) => r.output_type === 'text')
+ const structuredResults = results.filter((r) =>
+ r.output_type === 'json' || r.output_type === 'table' || r.output_type === 'chart',
+ )
+ const terminalText = textResults.map((r) => String(r.content)).join('')
+ const hasResults = results.length > 0 || downloadable.length > 0
+
+ const codePanel = (
+
+ {codeBlocks.length > 1 ? (
+
+ {codeBlocks.map((_, index) => (
+ setActiveCodeIndex(index)}>
+ {t('assistant.panel.codeBlock')} {index + 1}
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+
+ {hasResults ? (
+ <>
+
+
+
+
+ {downloadable.length > 0 ? (
+
+ {downloadable.map((file) => (
+
+ ))}
+
+ ) : null}
+ {terminalText ? (
+
+ {terminalText}
+
+ ) : null}
+ {structuredResults.map((output, index) => (
+
…}>
+
+
+ ))}
+
+
+ >
+ ) : null}
+
+ )
+
+ return (
+
+ {images.length > 0 ? (
+
+ setActiveView('code')}>
+ {t('assistant.panel.code')}
+
+ setActiveView('chart')}>
+ {t('assistant.panel.chart')}
+
+
+ ) : null}
+
+ {activeView === 'chart' && images.length > 0 ? (
+
+ {images.map((img, idx) => (
+
)})
+ ))}
+
+ ) : (
+ codePanel
+ )}
+
+ )
+}
+
+function StructuredResult({ output }: { output: ExecutionOutput }) {
+ if (output.output_type === 'json') {
+ const json =
+ typeof output.content === 'string'
+ ? output.content
+ : JSON.stringify(output.content, null, 2)
+ return (
+
+ )
+ }
+ if (output.output_type === 'table' && output.content && typeof output.content === 'object') {
+ return } />
+ }
+ if (output.output_type === 'chart' && output.content && typeof output.content === 'object') {
+ return } />
+ }
+ return null
+}
+
+function SectionBadge({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ )
+}
+
+function TabButton({
+ active,
+ onClick,
+ children,
+}: {
+ active: boolean
+ onClick: () => void
+ children: ReactNode
+}) {
+ return (
+
+ )
+}
diff --git a/src/features/assistant/execution/ExecutionOutputRenderer.test.tsx b/src/features/assistant/execution/ExecutionOutputRenderer.test.tsx
new file mode 100644
index 0000000..07b9747
--- /dev/null
+++ b/src/features/assistant/execution/ExecutionOutputRenderer.test.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { ExecutionOutputRenderer } from './ExecutionOutputRenderer'
+
+describe('ExecutionOutputRenderer skill load', () => {
+ it('shows the Persian catalog summary instead of English SKILL.md', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('مهارت بارگذاریشده')).toBeInTheDocument()
+ expect(screen.getByText('ریشهیاب علت')).toBeInTheDocument()
+ expect(screen.getByText(/شواهد چندبخشی را ترکیب میکند/)).toBeInTheDocument()
+ expect(screen.queryByText(/Kamidso Root Cause Investigator/)).not.toBeInTheDocument()
+ })
+
+ it('keeps the English skill body only in developer mode', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('ریشهیاب علت')).toBeInTheDocument()
+ expect(screen.getByText(/Kamidso Root Cause Investigator/)).toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/execution/ExecutionOutputRenderer.tsx b/src/features/assistant/execution/ExecutionOutputRenderer.tsx
new file mode 100644
index 0000000..aeaab8b
--- /dev/null
+++ b/src/features/assistant/execution/ExecutionOutputRenderer.tsx
@@ -0,0 +1,192 @@
+import { lazy, Suspense } from 'react'
+import type { ExecutionOutput } from '../types/chat'
+import { CodeBlock } from '../components/CodeBlock'
+import { MarkdownAnswer } from '../components/MarkdownAnswer'
+import { CodeExecutionView } from './CodeExecutionView'
+import { FileDownloadCard, parseDownloadableFile } from '../artifacts/FileDownloadCard'
+import { resolveApiUrl } from '../../../services/apiBase'
+import { t } from '../../../i18n'
+import { presentSkillByKey } from '../../skills/present'
+import { groupExecutionOutputs, stripCodeFences, type ExecutionOutputGroup } from './groupOutputs'
+
+const HtmlPreview = lazy(() => import('../components/HtmlPreview').then((m) => ({ default: m.HtmlPreview })))
+const TablePreview = lazy(() => import('../artifacts/TablePreview').then((m) => ({ default: m.TablePreview })))
+const ChartPreview = lazy(() => import('../artifacts/ChartPreview').then((m) => ({ default: m.ChartPreview })))
+const JsonPreview = lazy(() => import('../artifacts/JsonPreview').then((m) => ({ default: m.JsonPreview })))
+
+export function ExecutionOutputRenderer({
+ outputs,
+ developerMode,
+}: {
+ outputs: ExecutionOutput[]
+ developerMode?: boolean
+}) {
+ const groups = groupExecutionOutputs(outputs)
+ if (!groups.length) return null
+
+ return (
+
+ {groups.map((group, index) => (
+ …}>
+
+
+ ))}
+
+ )
+}
+
+function OutputGroup({
+ group,
+ developerMode,
+}: {
+ group: ExecutionOutputGroup
+ developerMode?: boolean
+}) {
+ if (group.type === 'skill-load') {
+ return
+ }
+ if (group.type === 'code-execution') {
+ return (
+
+ )
+ }
+ if (group.type === 'html-tabbed') {
+ const htmlContent = group.html.content
+ const html =
+ typeof htmlContent === 'object' && htmlContent && 'html' in (htmlContent as object)
+ ? String((htmlContent as { html?: string }).html ?? '')
+ : String(htmlContent ?? '')
+ return (
+
+ {group.code ? (
+
+ ) : null}
+
+
+ )
+ }
+ return
+}
+
+function SingleOutput({ output, developerMode }: { output: ExecutionOutput; developerMode?: boolean }) {
+ if (output.output_type === 'code') {
+ return (
+
+ )
+ }
+ if (output.output_type === 'html') {
+ return
+ }
+ if (output.output_type === 'table' && output.content && typeof output.content === 'object') {
+ return
+ }
+ if (output.output_type === 'chart' && output.content && typeof output.content === 'object') {
+ return } />
+ }
+ if (output.output_type === 'error') {
+ return (
+
+ {String(output.content)}
+
+ )
+ }
+ if (output.output_type === 'json') {
+ const json =
+ typeof output.content === 'string'
+ ? output.content
+ : JSON.stringify(output.content, null, 2)
+ return (
+
+ )
+ }
+ if (output.output_type === 'markdown' || output.output_type === 'text') {
+ if (output.output_type === 'text') {
+ return (
+
+
+ {t('assistant.panel.terminal')}
+
+
+ {String(output.content)}
+
+
+ )
+ }
+ return
+ }
+ if (output.output_type === 'image') {
+ return (
+
+ )
+ }
+ if (output.output_type === 'file') {
+ const file = parseDownloadableFile(output.content)
+ if (file) return
+ }
+ if (developerMode) {
+ return
+ }
+ return (
+
+ {typeof output.content === 'string' ? output.content : JSON.stringify(output.content, null, 2)}
+
+ )
+}
+
+function SkillLoadCard({
+ group,
+ developerMode,
+}: {
+ group: Extract
+ developerMode?: boolean
+}) {
+ const presented = presentSkillByKey(group.skillName)
+ const title = presented?.name || group.skillName
+ const description = presented?.description || ''
+ const domain = presented?.domain || ''
+
+ return (
+
+
+
{t('assistant.panel.skillLoaded')}
+
{title}
+ {description ?
{description}
: null}
+ {domain ?
{domain}
: null}
+
+ {developerMode ? (
+
+
+ {t('assistant.panel.terminal')}
+
+
+ {group.body}
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/execution/ExecutionPanel.tsx b/src/features/assistant/execution/ExecutionPanel.tsx
new file mode 100644
index 0000000..5a970dc
--- /dev/null
+++ b/src/features/assistant/execution/ExecutionPanel.tsx
@@ -0,0 +1,215 @@
+import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
+import { BookOpen, Database, FileText, FolderOpen, Loader2, Monitor, PanelLeftClose } from 'lucide-react'
+import type { KamidsoArtifact } from '../../../contracts/run'
+import type { ChatMessage, ExecutionOutput, ExecutionStep, TaskItem } from '../types/chat'
+import type { SessionFileSnapshot } from '../session-files/types'
+import { MarkdownAnswer } from '../components/MarkdownAnswer'
+import { StepTimeline } from '../components/StepTimeline'
+import { TaskPlanCard } from '../components/TaskPlanCard'
+import { ExecutionOutputRenderer } from './ExecutionOutputRenderer'
+import { StepDetailCard } from './StepDetailCard'
+import { PanelTabBar } from './PanelTabBar'
+import { TaskFilesTab } from './TaskFilesTab'
+import { t } from '../../../i18n'
+
+const JsonPreview = lazy(() => import('../artifacts/JsonPreview').then((m) => ({ default: m.JsonPreview })))
+
+type PanelView = 'execution' | 'files' | 'summary' | 'technical'
+
+export function ExecutionPanel({
+ steps,
+ outputs,
+ activeStepId,
+ onStepSelect,
+ taskPlan,
+ summary,
+ artifacts,
+ inputFiles = [],
+ attachedKnowledge,
+ attachedDb,
+ loading,
+ developerMode,
+ onCollapse,
+ className = '',
+}: {
+ steps: ExecutionStep[]
+ outputs: Record
+ activeStepId?: string | null
+ onStepSelect?: (stepId: string) => void
+ taskPlan?: TaskItem[]
+ summary?: string
+ artifacts?: KamidsoArtifact[]
+ inputFiles?: readonly SessionFileSnapshot[]
+ attachedKnowledge?: ChatMessage['attachedKnowledge']
+ attachedDb?: ChatMessage['attachedDb']
+ loading?: boolean
+ developerMode?: boolean
+ onCollapse?: () => void
+ className?: string
+}) {
+ const [view, setView] = useState('execution')
+ const [localStepId, setLocalStepId] = useState(null)
+
+ const resolvedStepId = activeStepId ?? localStepId ?? steps[steps.length - 1]?.id ?? null
+ const activeStep = steps.find((s) => s.id === resolvedStepId)
+ const stepOutputs = resolvedStepId ? outputs[resolvedStepId] || [] : []
+ const fileCount = inputFiles.length + (artifacts?.length ?? 0)
+ const hasSources = Boolean(attachedKnowledge?.name || attachedDb?.db_name)
+
+ useEffect(() => {
+ if (activeStepId) setLocalStepId(activeStepId)
+ else if (!localStepId && steps.length) {
+ setLocalStepId(steps[steps.length - 1].id)
+ }
+ }, [activeStepId, steps, localStepId])
+
+ useEffect(() => {
+ if (loading) setView('execution')
+ }, [loading])
+
+ useEffect(() => {
+ if (activeStepId) setView('execution')
+ }, [activeStepId])
+
+ const tabs = useMemo(
+ () =>
+ [
+ { id: 'execution' as const, label: t('assistant.panel.executionSteps'), icon: Monitor },
+ fileCount > 0
+ ? { id: 'files' as const, label: t('assistant.panel.taskFiles'), icon: FolderOpen, badge: fileCount }
+ : null,
+ summary?.trim()
+ ? { id: 'summary' as const, label: t('assistant.panel.summary'), icon: FileText }
+ : null,
+ developerMode
+ ? { id: 'technical' as const, label: t('assistant.panel.technical'), icon: Monitor }
+ : null,
+ ].filter(Boolean) as Array<{ id: PanelView; label: string; icon: typeof Monitor; badge?: number }>,
+ [fileCount, summary, developerMode],
+ )
+
+ return (
+
+
+
+
+ {t('assistant.execution.title')}
+ {loading ? : null}
+
+ {onCollapse ? (
+
+ ) : null}
+
+
+ {tabs.length > 1 ?
setView(id as PanelView)} /> : null}
+
+
+ {view === 'execution' ? (
+
+ {hasSources ? (
+
+ {t('assistant.panel.sourcesThisTurn')}
+
+ {attachedKnowledge?.name ? (
+
+
+
+ {t('assistant.panel.attachedKnowledge')}: {attachedKnowledge.name}
+
+
+ ) : null}
+ {attachedDb?.db_name ? (
+
+
+
+ {t('assistant.composer.database')}: {attachedDb.db_name}
+
+
+ ) : null}
+
+
+ ) : null}
+ {taskPlan?.length ?
: null}
+
+ {steps.length ? (
+
{
+ setLocalStepId(stepId)
+ onStepSelect?.(stepId)
+ }}
+ />
+ ) : null}
+
+ {activeStep ? : null}
+
+ {stepOutputs.length ? (
+
+
+
+ ) : loading ? (
+
+
+
{t('assistant.execution.processing')}
+
+ ) : steps.length ? (
+ {t('assistant.panel.noOutput')}
+ ) : (
+ {t('assistant.panel.noSteps')}
+ )}
+
+ {steps.length > 1 ? (
+
+ {steps.map((step, index) => (
+
+ ))}
+
+ ) : null}
+
+ ) : null}
+
+ {view === 'files' ? (
+
+ ) : null}
+
+ {view === 'summary' ? (
+ summary?.trim() ? (
+
+
+
+ ) : (
+
{t('assistant.panel.summaryPlaceholder')}
+ )
+ ) : null}
+
+ {view === 'technical' ? (
+
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/features/assistant/execution/ManusStepCard.tsx b/src/features/assistant/execution/ManusStepCard.tsx
new file mode 100644
index 0000000..9cacb60
--- /dev/null
+++ b/src/features/assistant/execution/ManusStepCard.tsx
@@ -0,0 +1,62 @@
+import { CheckCircle2, Circle, Loader2, XCircle } from 'lucide-react'
+import type { ExecutionStep } from '../types/chat'
+import { stepCategoryLabel, stepTypeLabel, toolActionName } from '../adapters/stepLabels'
+import { toolIconForStep } from './toolIcons'
+
+export function ManusStepCard({
+ step,
+ active,
+ onClick,
+ compact,
+}: {
+ step: ExecutionStep
+ active?: boolean
+ onClick?: () => void
+ compact?: boolean
+}) {
+ const label = stepTypeLabel(step.action, step.title)
+ const category = stepCategoryLabel(step.action)
+ const actionName = toolActionName(step.action)
+ const Icon = toolIconForStep(step)
+ const spinning = step.status === 'running'
+
+ return (
+
+ )
+}
diff --git a/src/features/assistant/execution/PanelTabBar.tsx b/src/features/assistant/execution/PanelTabBar.tsx
new file mode 100644
index 0000000..5e5b5a9
--- /dev/null
+++ b/src/features/assistant/execution/PanelTabBar.tsx
@@ -0,0 +1,50 @@
+import type { LucideIcon } from 'lucide-react'
+
+export type PanelTabItem = {
+ id: string
+ label: string
+ icon: LucideIcon
+ badge?: number
+}
+
+export function PanelTabBar({
+ tabs,
+ active,
+ onChange,
+}: {
+ tabs: PanelTabItem[]
+ active: string
+ onChange: (id: string) => void
+}) {
+ return (
+
+ {tabs.map((tab) => {
+ const Icon = tab.icon
+ const isActive = active === tab.id
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/features/assistant/execution/ParallelTasksPanel.tsx b/src/features/assistant/execution/ParallelTasksPanel.tsx
new file mode 100644
index 0000000..78535d4
--- /dev/null
+++ b/src/features/assistant/execution/ParallelTasksPanel.tsx
@@ -0,0 +1,13 @@
+/** Shown only when backend emits parallel task events. */
+export function ParallelTasksPanel({ tasks }: { tasks?: Array<{ id: string; title: string; status: string }> }) {
+ if (!tasks?.length) return null
+ return (
+
+ {tasks.map((task) => (
+ -
+ {task.title} — {task.status}
+
+ ))}
+
+ )
+}
diff --git a/src/features/assistant/execution/StatusBadge.tsx b/src/features/assistant/execution/StatusBadge.tsx
new file mode 100644
index 0000000..cb4a9d3
--- /dev/null
+++ b/src/features/assistant/execution/StatusBadge.tsx
@@ -0,0 +1,26 @@
+import { t } from '../../../i18n'
+import type { ExecutionStep } from '../types/chat'
+
+export function StatusBadge({ status }: { status: ExecutionStep['status'] }) {
+ const styles: Record = {
+ running: 'bg-blue-50 text-blue-600 ring-blue-200/60 dark:bg-blue-500/10 dark:text-blue-300 dark:ring-blue-500/20',
+ done: 'bg-emerald-50 text-emerald-600 ring-emerald-200/60 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/20',
+ completed: 'bg-emerald-50 text-emerald-600 ring-emerald-200/60 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/20',
+ failed: 'bg-red-50 text-red-600 ring-red-200/60 dark:bg-red-500/10 dark:text-red-300 dark:ring-red-500/20',
+ pending: 'bg-slate-100 text-slate-500 ring-slate-200/60 dark:bg-white/5 dark:text-slate-400 dark:ring-white/10',
+ }
+
+ const labels: Record = {
+ running: t('assistant.panel.statusRunning'),
+ done: t('assistant.panel.statusDone'),
+ completed: t('assistant.panel.statusDone'),
+ failed: t('assistant.panel.statusFailed'),
+ pending: t('assistant.panel.statusPending'),
+ }
+
+ return (
+
+ {labels[status] ?? status}
+
+ )
+}
diff --git a/src/features/assistant/execution/StepDetailCard.tsx b/src/features/assistant/execution/StepDetailCard.tsx
new file mode 100644
index 0000000..3edc6f3
--- /dev/null
+++ b/src/features/assistant/execution/StepDetailCard.tsx
@@ -0,0 +1,55 @@
+import { ChevronDown, ChevronUp } from 'lucide-react'
+import { useState } from 'react'
+import type { ExecutionStep } from '../types/chat'
+import { stepCategoryLabel, stepDetailLabel, stepTypeLabel, toolActionName } from '../adapters/stepLabels'
+import { toolIconForStep } from './toolIcons'
+import { StatusBadge } from './StatusBadge'
+
+export function StepDetailCard({ step }: { step: ExecutionStep }) {
+ const [collapsed, setCollapsed] = useState(false)
+ const Icon = toolIconForStep(step)
+ const category = stepCategoryLabel(step.action)
+ const actionName = toolActionName(step.action)
+ const title = stepTypeLabel(step.action, step.title)
+ const detail = stepDetailLabel(step.detail)
+
+ return (
+
+
+ {!collapsed && detail ? (
+
+ {detail}
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/execution/SubAgentSection.tsx b/src/features/assistant/execution/SubAgentSection.tsx
new file mode 100644
index 0000000..9da5b81
--- /dev/null
+++ b/src/features/assistant/execution/SubAgentSection.tsx
@@ -0,0 +1,13 @@
+/** Shown only when backend emits sub-agent events. */
+export function SubAgentSection({ agents }: { agents?: Array<{ id: string; name: string; status: string }> }) {
+ if (!agents?.length) return null
+ return (
+
+ {agents.map((agent) => (
+
+ {agent.name} — {agent.status}
+
+ ))}
+
+ )
+}
diff --git a/src/features/assistant/execution/TaskFilesTab.tsx b/src/features/assistant/execution/TaskFilesTab.tsx
new file mode 100644
index 0000000..9a1295b
--- /dev/null
+++ b/src/features/assistant/execution/TaskFilesTab.tsx
@@ -0,0 +1,112 @@
+import { Download, FileSpreadsheet, FolderOpen, Image as ImageIcon } from 'lucide-react'
+import type { KamidsoArtifact } from '../../../contracts/run'
+import type { SessionFileSnapshot } from '../session-files/types'
+import { FileDownloadCard, parseDownloadableFile } from '../artifacts/FileDownloadCard'
+import { resolveApiUrl } from '../../../services/apiBase'
+import { t } from '../../../i18n'
+
+function formatSize(bytes?: number): string {
+ if (!bytes) return ''
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
+}
+
+export function TaskFilesTab({
+ inputFiles,
+ artifacts,
+}: {
+ inputFiles: readonly SessionFileSnapshot[]
+ artifacts?: KamidsoArtifact[]
+}) {
+ const hasInput = inputFiles.length > 0
+ const hasArtifacts = (artifacts?.length ?? 0) > 0
+
+ if (!hasInput && !hasArtifacts) {
+ return (
+
+
+
{t('assistant.panel.noTaskFiles')}
+
+ )
+ }
+
+ return (
+
+ {hasInput ? (
+
+
+
+ {t('assistant.panel.uploadedFiles')} · {inputFiles.length}
+
+
+
+ {inputFiles.map((file) => (
+
+
+
+
+
+
{file.name}
+
+ {file.media_type || 'file'}
+ {file.size ? ` · ${formatSize(file.size)}` : ''}
+
+
+
+ ))}
+
+
+ ) : null}
+
+ {hasArtifacts ? (
+
+
+
+ {t('assistant.panel.generatedOutputs')} · {artifacts!.length}
+
+
+
+ {artifacts!.map((artifact) => {
+ const download = artifact.type === 'file' ? parseDownloadableFile(artifact.content) : null
+ if (download) {
+ return
+ }
+ const imageUrl =
+ artifact.type === 'image' && typeof artifact.content === 'string'
+ ? resolveApiUrl(artifact.content)
+ : null
+ return (
+
+ )
+ })}
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/execution/TaskPlanCard.tsx b/src/features/assistant/execution/TaskPlanCard.tsx
new file mode 100644
index 0000000..2e77229
--- /dev/null
+++ b/src/features/assistant/execution/TaskPlanCard.tsx
@@ -0,0 +1 @@
+export { TaskPlanCard } from '../components/TaskPlanCard'
diff --git a/src/features/assistant/execution/ThinkingProcess.tsx b/src/features/assistant/execution/ThinkingProcess.tsx
new file mode 100644
index 0000000..10ba776
--- /dev/null
+++ b/src/features/assistant/execution/ThinkingProcess.tsx
@@ -0,0 +1,47 @@
+import { useEffect, useState } from 'react'
+
+export function ThinkingProcess({
+ content,
+ enableAnimation = true,
+ animationSpeed = 30,
+ className = '',
+}: {
+ content: string
+ enableAnimation?: boolean
+ animationSpeed?: number
+ className?: string
+}) {
+ const [displayedText, setDisplayedText] = useState('')
+ const [isComplete, setIsComplete] = useState(false)
+
+ useEffect(() => {
+ if (!enableAnimation) {
+ setDisplayedText(content)
+ setIsComplete(true)
+ return
+ }
+ setDisplayedText('')
+ setIsComplete(false)
+ let index = 0
+ const timer = setInterval(() => {
+ if (index < content.length) {
+ const chunkSize = Math.min(3, content.length - index)
+ setDisplayedText((prev) => prev + content.slice(index, index + chunkSize))
+ index += chunkSize
+ } else {
+ setIsComplete(true)
+ clearInterval(timer)
+ }
+ }, animationSpeed)
+ return () => clearInterval(timer)
+ }, [content, enableAnimation, animationSpeed])
+
+ if (!content) return null
+
+ return (
+
+ {displayedText}
+ {!isComplete ? : null}
+
+ )
+}
diff --git a/src/features/assistant/execution/TracePanel.tsx b/src/features/assistant/execution/TracePanel.tsx
new file mode 100644
index 0000000..b80ff71
--- /dev/null
+++ b/src/features/assistant/execution/TracePanel.tsx
@@ -0,0 +1,11 @@
+/** Developer-only trace panel — renders only when backend provides trace data. */
+export function TracePanel({ trace }: { trace?: unknown[] }) {
+ if (!trace?.length) {
+ return Trace در دسترس نیست.
+ }
+ return (
+
+ {JSON.stringify(trace, null, 2)}
+
+ )
+}
diff --git a/src/features/assistant/execution/groupOutputs.test.ts b/src/features/assistant/execution/groupOutputs.test.ts
new file mode 100644
index 0000000..a857be6
--- /dev/null
+++ b/src/features/assistant/execution/groupOutputs.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest'
+import { groupExecutionOutputs, stripCodeFences } from './groupOutputs'
+
+describe('groupExecutionOutputs', () => {
+ it('groups code followed by text into code-execution', () => {
+ const groups = groupExecutionOutputs([
+ { output_type: 'code', content: 'print(1)' },
+ { output_type: 'text', content: '1\n' },
+ ])
+ expect(groups).toHaveLength(1)
+ expect(groups[0].type).toBe('code-execution')
+ if (groups[0].type === 'code-execution') {
+ expect(groups[0].codes).toHaveLength(1)
+ expect(groups[0].results).toHaveLength(1)
+ }
+ })
+
+ it('groups code followed by json and table into code-execution', () => {
+ const groups = groupExecutionOutputs([
+ { output_type: 'code', content: 'print("hi")' },
+ { output_type: 'json', content: { shape: [1, 2] } },
+ { output_type: 'table', content: { columns: ['a'], rows: [['1']] } },
+ ])
+ expect(groups).toHaveLength(1)
+ expect(groups[0].type).toBe('code-execution')
+ if (groups[0].type === 'code-execution') {
+ expect(groups[0].results).toHaveLength(2)
+ expect(groups[0].results[0].output_type).toBe('json')
+ expect(groups[0].results[1].output_type).toBe('table')
+ }
+ })
+
+ it('groups code followed by file export chunks', () => {
+ const groups = groupExecutionOutputs([
+ { output_type: 'code', content: 'df.to_excel(...)' },
+ { output_type: 'text', content: 'saved\n' },
+ { output_type: 'file', content: { url: '/files/a_buyers.xlsx', name: 'buyers.xlsx' } },
+ { output_type: 'text', content: 'Generated downloadable files…' },
+ ])
+ expect(groups).toHaveLength(1)
+ expect(groups[0].type).toBe('code-execution')
+ if (groups[0].type === 'code-execution') {
+ expect(groups[0].files).toHaveLength(1)
+ expect(groups[0].files[0].content).toEqual({
+ url: '/files/a_buyers.xlsx',
+ name: 'buyers.xlsx',
+ })
+ expect(groups[0].results).toHaveLength(2)
+ }
+ })
+
+ it('groups load_skill terminal text with following markdown', () => {
+ const groups = groupExecutionOutputs([
+ { output_type: 'text', content: 'Skill: root-cause-investigator' },
+ { output_type: 'text', content: 'File path: ---' },
+ { output_type: 'text', content: '---' },
+ { output_type: 'markdown', content: '# Kamidso Root Cause Investigator' },
+ ])
+ expect(groups).toHaveLength(1)
+ expect(groups[0].type).toBe('skill-load')
+ if (groups[0].type === 'skill-load') {
+ expect(groups[0].skillName).toBe('root-cause-investigator')
+ expect(groups[0].body).toContain('Kamidso Root Cause Investigator')
+ }
+ })
+})
+
+describe('stripCodeFences', () => {
+ it('removes markdown fences', () => {
+ expect(stripCodeFences('```python\nx=1\n```')).toBe('x=1\n')
+ })
+})
diff --git a/src/features/assistant/execution/groupOutputs.ts b/src/features/assistant/execution/groupOutputs.ts
new file mode 100644
index 0000000..7ce318d
--- /dev/null
+++ b/src/features/assistant/execution/groupOutputs.ts
@@ -0,0 +1,136 @@
+import type { ExecutionOutput } from '../types/chat'
+import { isLoadSkillText, parseLoadSkillName } from './skillOutput'
+
+export type ExecutionOutputGroup =
+ | {
+ type: 'code-execution'
+ codes: ExecutionOutput[]
+ results: ExecutionOutput[]
+ images: ExecutionOutput[]
+ files: ExecutionOutput[]
+ }
+ | { type: 'html-tabbed'; code?: ExecutionOutput; html: ExecutionOutput }
+ | {
+ type: 'skill-load'
+ skillName: string
+ body: string
+ rawOutputs: ExecutionOutput[]
+ }
+ | { type: 'single'; output: ExecutionOutput }
+
+/** Group step outputs the same way upstream DBGPT ManusRightPanel does. */
+export function groupExecutionOutputs(outputs: ExecutionOutput[]): ExecutionOutputGroup[] {
+ const groups: ExecutionOutputGroup[] = []
+ let i = 0
+ while (i < outputs.length) {
+ if (outputs[i].output_type === 'code') {
+ const codes: ExecutionOutput[] = [outputs[i]]
+ i += 1
+ while (i < outputs.length && outputs[i].output_type === 'code') {
+ codes.push(outputs[i])
+ i += 1
+ }
+ if (i < outputs.length && outputs[i].output_type === 'html') {
+ groups.push({
+ type: 'html-tabbed',
+ code: { ...codes[0], content: codes.map((c) => String(c.content)).join('\n') },
+ html: outputs[i],
+ })
+ i += 1
+ } else {
+ const results: ExecutionOutput[] = []
+ while (
+ i < outputs.length &&
+ (outputs[i].output_type === 'text' ||
+ outputs[i].output_type === 'json' ||
+ outputs[i].output_type === 'table' ||
+ outputs[i].output_type === 'chart')
+ ) {
+ results.push(outputs[i])
+ i += 1
+ }
+ const images: ExecutionOutput[] = []
+ while (i < outputs.length && outputs[i].output_type === 'image') {
+ images.push(outputs[i])
+ i += 1
+ }
+ const files: ExecutionOutput[] = []
+ while (i < outputs.length && outputs[i].output_type === 'file') {
+ files.push(outputs[i])
+ i += 1
+ }
+ // File chunks may appear before trailing text summary — also accept interleaved.
+ while (
+ i < outputs.length &&
+ (outputs[i].output_type === 'file' || outputs[i].output_type === 'text')
+ ) {
+ if (outputs[i].output_type === 'file') files.push(outputs[i])
+ else results.push(outputs[i])
+ i += 1
+ }
+ groups.push({ type: 'code-execution', codes, results, images, files })
+ }
+ } else if (outputs[i].output_type === 'html') {
+ groups.push({ type: 'html-tabbed', html: outputs[i] })
+ i += 1
+ } else if (outputs[i].output_type === 'markdown') {
+ const markdownParts: string[] = [String(outputs[i].content)]
+ const firstMarkdown = outputs[i]
+ i += 1
+ while (i < outputs.length && outputs[i].output_type === 'markdown') {
+ markdownParts.push(String(outputs[i].content))
+ i += 1
+ }
+ groups.push({ type: 'single', output: { ...firstMarkdown, content: markdownParts.join('\n\n') } })
+ } else if (outputs[i].output_type === 'text') {
+ const firstText = outputs[i]
+ const firstContent = String(firstText.content ?? '')
+ if (isLoadSkillText(firstContent)) {
+ const rawOutputs: ExecutionOutput[] = [firstText]
+ const parts = [firstContent]
+ i += 1
+ while (i < outputs.length && outputs[i].output_type === 'text') {
+ rawOutputs.push(outputs[i])
+ parts.push(String(outputs[i].content ?? ''))
+ i += 1
+ }
+ while (i < outputs.length && outputs[i].output_type === 'markdown') {
+ rawOutputs.push(outputs[i])
+ parts.push(String(outputs[i].content ?? ''))
+ i += 1
+ }
+ groups.push({
+ type: 'skill-load',
+ skillName: parseLoadSkillName(firstContent) || '',
+ body: parts.join('\n\n'),
+ rawOutputs,
+ })
+ } else {
+ const textParts: string[] = [firstContent]
+ i += 1
+ while (i < outputs.length && outputs[i].output_type === 'text') {
+ textParts.push(String(outputs[i].content))
+ i += 1
+ }
+ groups.push({ type: 'single', output: { ...firstText, content: textParts.join('\n\n') } })
+ }
+ } else if (outputs[i].output_type === 'error') {
+ const errorParts: string[] = [String(outputs[i].content)]
+ const firstError = outputs[i]
+ i += 1
+ while (i < outputs.length && outputs[i].output_type === 'error') {
+ errorParts.push(String(outputs[i].content))
+ i += 1
+ }
+ groups.push({ type: 'single', output: { ...firstError, content: errorParts.join('') } })
+ } else {
+ groups.push({ type: 'single', output: outputs[i] })
+ i += 1
+ }
+ }
+ return groups
+}
+
+export function stripCodeFences(code: string): string {
+ return code.replace(/^\s*```[a-zA-Z]*\s*/m, '').replace(/```\s*$/m, '')
+}
diff --git a/src/features/assistant/execution/skillOutput.ts b/src/features/assistant/execution/skillOutput.ts
new file mode 100644
index 0000000..d717dfc
--- /dev/null
+++ b/src/features/assistant/execution/skillOutput.ts
@@ -0,0 +1,11 @@
+const SKILL_LINE = /^\s*Skill:\s*(.+?)\s*$/m
+
+export function parseLoadSkillName(content: string): string | null {
+ const match = content.match(SKILL_LINE)
+ const name = match?.[1]?.trim()
+ return name || null
+}
+
+export function isLoadSkillText(content: string): boolean {
+ return Boolean(parseLoadSkillName(content))
+}
diff --git a/src/features/assistant/execution/toolIcons.tsx b/src/features/assistant/execution/toolIcons.tsx
new file mode 100644
index 0000000..a9b9025
--- /dev/null
+++ b/src/features/assistant/execution/toolIcons.tsx
@@ -0,0 +1,37 @@
+import {
+ BookOpen,
+ Code2,
+ Database,
+ FileSearch,
+ FileSpreadsheet,
+ Loader2,
+ Search,
+ Terminal,
+ Wrench,
+ type LucideIcon,
+} from 'lucide-react'
+import type { ExecutionStep } from '../types/chat'
+
+const ACTION_ICONS: Record = {
+ code_interpreter: Code2,
+ execute_analysis: Terminal,
+ load_file: FileSpreadsheet,
+ file_reader: FileSearch,
+ sql_executor: Database,
+ knowledge_search: Search,
+ read_file: FileSearch,
+ write_file: FileSpreadsheet,
+ run_python: Code2,
+}
+
+export function toolIconForStep(step: ExecutionStep): LucideIcon {
+ if (step.status === 'running') return Loader2
+ const action = step.action?.toLowerCase() ?? ''
+ for (const [key, icon] of Object.entries(ACTION_ICONS)) {
+ if (action.includes(key)) return icon
+ }
+ if (step.title?.includes('فایل') || step.title?.includes('file')) return FileSpreadsheet
+ if (step.title?.includes('SQL') || step.title?.includes('داده')) return Database
+ if (step.title?.includes('دانش')) return BookOpen
+ return Wrench
+}
diff --git a/src/features/assistant/files/index.ts b/src/features/assistant/files/index.ts
new file mode 100644
index 0000000..6951d74
--- /dev/null
+++ b/src/features/assistant/files/index.ts
@@ -0,0 +1,21 @@
+/** Workspace files module — Kamidso naming over session-files implementation. */
+export type {
+ KamidsoFile,
+ SessionFileSnapshot as WorkspaceFileSnapshot,
+ DraftFile,
+ LegacyServerFile,
+ UploadCapabilities,
+ SessionFilesSendSnapshot as WorkspaceFilesSendSnapshot,
+} from '../session-files/types'
+
+export {
+ sessionFilesApi as workspaceFilesApi,
+ previewFile,
+ snapshotsForSend,
+ useSessionFiles as useFiles,
+} from '../session-files'
+
+export { default as AttachmentRail, AttachmentRailAddButton } from '../session-files/AttachmentRail'
+export { default as AttachmentPreview, AttachmentPreviewCloseButton, AttachmentPreviewPanelTitle } from '../session-files/AttachmentPreview'
+export { default as AttachmentMessageCards } from '../session-files/AttachmentMessageCards'
+export { default as AttachmentMessageGroup } from '../session-files/AttachmentMessageGroup'
diff --git a/src/features/assistant/hooks/useAssistantStream.ts b/src/features/assistant/hooks/useAssistantStream.ts
new file mode 100644
index 0000000..409a99f
--- /dev/null
+++ b/src/features/assistant/hooks/useAssistantStream.ts
@@ -0,0 +1,2 @@
+/** Backward-compatible alias — prefer `useRunStream` from `../stream/useRunStream`. */
+export { useRunStream as useAssistantStream } from '../stream/useRunStream'
diff --git a/src/features/assistant/input/EnhancedChatInput.tsx b/src/features/assistant/input/EnhancedChatInput.tsx
new file mode 100644
index 0000000..4587106
--- /dev/null
+++ b/src/features/assistant/input/EnhancedChatInput.tsx
@@ -0,0 +1,165 @@
+import { Paperclip, Send, Square } from 'lucide-react'
+import { useEffect, useRef, useState } from 'react'
+import { t } from '../../../i18n'
+import AttachmentRail from '../session-files/AttachmentRail'
+import type { DraftFile, LegacyServerFile } from '../session-files'
+import { conversationWidthClass } from '../workspace/conversationLayout'
+import { DATA_FILE_ACCEPT } from '../session-files/fileKind'
+import type { ResourceOption } from '../types/chat'
+
+export function EnhancedChatInput({
+ query,
+ onQueryChange,
+ onSend,
+ onStop,
+ loading,
+ drafts,
+ legacyFiles,
+ onRemoveFile,
+ onRetryFile,
+ onAddFiles,
+ pinnedDatabases = [],
+ pinnedKnowledge = null,
+ onRemoveDatabase,
+ onClearKnowledge,
+ toolbar,
+ variant = 'dock',
+}: {
+ query: string
+ onQueryChange: (value: string) => void
+ onSend: () => void
+ onStop: () => void
+ loading: boolean
+ drafts: readonly DraftFile[]
+ legacyFiles?: readonly LegacyServerFile[]
+ onRemoveFile: (clientId: string) => void
+ onRetryFile: (clientId: string) => void
+ onAddFiles: (files: FileList | File[]) => void
+ pinnedDatabases?: readonly ResourceOption[]
+ pinnedKnowledge?: ResourceOption | null
+ onRemoveDatabase?: (id: string) => void
+ onClearKnowledge?: () => void
+ toolbar?: React.ReactNode
+ variant?: 'hero' | 'dock'
+}) {
+ const fileInputRef = useRef(null)
+ const textareaRef = useRef(null)
+ const [focused, setFocused] = useState(false)
+ const isHero = variant === 'hero'
+ const canSend = Boolean(query.trim() || drafts.length > 0 || (legacyFiles?.length ?? 0) > 0)
+ const hasPins = Boolean(
+ drafts.length ||
+ (legacyFiles?.length ?? 0) ||
+ pinnedDatabases.length ||
+ pinnedKnowledge,
+ )
+
+ useEffect(() => {
+ const el = textareaRef.current
+ if (!el) return
+ el.style.height = 'auto'
+ el.style.height = `${Math.min(Math.max(el.scrollHeight, 40), 132)}px`
+ }, [query])
+
+ return (
+
+
+
+ {hasPins ? (
+
+ ) : null}
+
+
+
+
+
+ {toolbar ? (
+
+ {toolbar}
+
+ ) : null}
+
+
+
+
{
+ if (e.target.files?.length) onAddFiles(Array.from(e.target.files))
+ e.target.value = ''
+ }}
+ />
+
+ )
+}
diff --git a/src/features/assistant/input/ModelSwitcher.tsx b/src/features/assistant/input/ModelSwitcher.tsx
new file mode 100644
index 0000000..4480dcf
--- /dev/null
+++ b/src/features/assistant/input/ModelSwitcher.tsx
@@ -0,0 +1,33 @@
+import { Cpu } from 'lucide-react'
+import { SelectMenu } from '../../../components/ui/SelectMenu'
+
+const PILL =
+ 'h-8 max-w-[8.5rem] rounded-full border-line/80 bg-transparent px-2 text-xs hover:border-accent/30 hover:bg-white'
+
+export function ModelSwitcher({
+ models,
+ value,
+ onChange,
+}: {
+ models: string[]
+ value?: string
+ onChange: (model: string) => void
+}) {
+ const options = (models.length ? models : ['auto']).map((m) => ({
+ value: m,
+ label: m === 'auto' ? 'خودکار' : m,
+ }))
+
+ return (
+ }
+ minMenuWidth={180}
+ triggerClassName={PILL}
+ />
+ )
+}
diff --git a/src/features/assistant/input/ResourcePicker.test.tsx b/src/features/assistant/input/ResourcePicker.test.tsx
new file mode 100644
index 0000000..5624170
--- /dev/null
+++ b/src/features/assistant/input/ResourcePicker.test.tsx
@@ -0,0 +1,104 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import { ResourcePicker } from './ResourcePicker'
+import type { KamidsoFile } from '../session-files'
+
+const book: KamidsoFile = {
+ id: 'file_book1',
+ name: 'Book1 (1).xlsx',
+ title: 'Book1 (1)',
+ size_bytes: 1200,
+ mime_type: 'application/vnd.ms-excel',
+ status: 'ready',
+ created_at: '2026-01-01',
+ kind: 'excel',
+}
+
+const sales: KamidsoFile = {
+ id: 'file_sales',
+ name: 'sales.xlsx',
+ title: 'sales',
+ size_bytes: 800,
+ mime_type: 'application/vnd.ms-excel',
+ status: 'ready',
+ created_at: '2026-01-01',
+ kind: 'excel',
+}
+
+const policy: KamidsoFile = {
+ id: 'file_policy',
+ name: 'policy.pdf',
+ title: 'سیاست',
+ size_bytes: 2400,
+ mime_type: 'application/pdf',
+ status: 'ready',
+ created_at: '2026-01-01',
+ kind: 'pdf',
+}
+
+describe('ResourcePicker', () => {
+ it('merges connections and data files, and hides knowledge documents', async () => {
+ const onToggle = vi.fn()
+ const onSelectDbs = vi.fn()
+ render(
+ ,
+ )
+
+ await userEvent.click(screen.getByRole('button', { name: 'داده' }))
+ expect(screen.getByRole('option', { name: /فروش/ })).toBeInTheDocument()
+ expect(screen.getByRole('option', { name: /Book1 \(1\)/ })).toBeInTheDocument()
+ expect(screen.queryByRole('option', { name: /سیاست/ })).not.toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('option', { name: /Book1 \(1\)/ }))
+ expect(onToggle).toHaveBeenCalledWith(book)
+ })
+
+ it('keeps the menu open so several databases and files can be attached', async () => {
+ const onToggle = vi.fn()
+ const onSelectDbs = vi.fn()
+ render(
+ ,
+ )
+
+ await userEvent.click(screen.getByRole('button', { name: 'داده' }))
+ expect(screen.getByRole('option', { name: /فروش/ })).toHaveAttribute('aria-selected', 'true')
+ expect(screen.getByRole('option', { name: /Book1 \(1\)/ })).toHaveAttribute('aria-selected', 'true')
+
+ await userEvent.click(screen.getByRole('option', { name: /عملیات/ }))
+ expect(onSelectDbs).toHaveBeenCalledWith([
+ { id: 'db_sales', name: 'فروش', type: 'postgres' },
+ { id: 'db_ops', name: 'عملیات', type: 'mongodb' },
+ ])
+ expect(screen.getByRole('option', { name: /فروش/ })).toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('option', { name: /sales/ }))
+ expect(onToggle).toHaveBeenCalledWith(sales)
+ expect(screen.getByRole('listbox', { name: 'داده' })).toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/input/ResourcePicker.tsx b/src/features/assistant/input/ResourcePicker.tsx
new file mode 100644
index 0000000..a8d8c4b
--- /dev/null
+++ b/src/features/assistant/input/ResourcePicker.tsx
@@ -0,0 +1,147 @@
+import { BookOpen, Database, FileSpreadsheet, Upload } from 'lucide-react'
+import { t } from '../../../i18n'
+import { SelectMenu } from '../../../components/ui/SelectMenu'
+import type { ResourceOption } from '../types/chat'
+import { ToolbarChipIcon, toolbarChipClass } from './toolbarChip'
+import type { KamidsoFile } from '../session-files'
+import { fileKindLine, isDataFileKind } from '../session-files/fileKind'
+
+const CONN_PREFIX = 'conn:'
+const FILE_PREFIX = 'file:'
+
+export function ResourcePicker({
+ databases,
+ knowledge,
+ selectedDbs,
+ selectedKnowledge,
+ onSelectDbs,
+ onSelectKnowledge,
+ libraryFiles = [],
+ attachedFileIds = [],
+ onToggleLibraryFile,
+ onUploadNew,
+}: {
+ databases: ResourceOption[]
+ knowledge: ResourceOption[]
+ selectedDbs: ResourceOption[]
+ selectedKnowledge: ResourceOption | null
+ onSelectDbs: (items: ResourceOption[]) => void
+ onSelectKnowledge: (item: ResourceOption | null) => void
+ libraryFiles?: KamidsoFile[]
+ attachedFileIds?: readonly string[]
+ onToggleLibraryFile?: (file: KamidsoFile) => void
+ onUploadNew?: () => void
+}) {
+ const dataFiles = libraryFiles.filter((file) => isDataFileKind(file.kind))
+ const attached = dataFiles.filter((file) => attachedFileIds.includes(file.id))
+ const names = [
+ ...selectedDbs.map((item) => item.name),
+ ...attached.map((file) => file.title?.trim() || file.name),
+ ]
+ const dataActive = names.length > 0
+ const selectedDataValues = dataActive
+ ? [
+ ...selectedDbs.map((item) => `${CONN_PREFIX}${item.id}`),
+ ...attached.map((file) => `${FILE_PREFIX}${file.id}`),
+ ]
+ : ['']
+ const dataValue = selectedDataValues[0] && selectedDataValues[0] !== '' ? selectedDataValues[0] : ''
+
+ return (
+
+ ({
+ value: `${CONN_PREFIX}${item.id}`,
+ label: item.name,
+ description: item.type || t('assistant.composer.dataConnection'),
+ group: t('assistant.composer.dataConnections'),
+ })),
+ ...dataFiles.map((file) => ({
+ value: `${FILE_PREFIX}${file.id}`,
+ label: file.title?.trim() || file.name,
+ description: fileKindLine(file.kind),
+ group: t('assistant.composer.dataFiles'),
+ })),
+ ]}
+ onChange={(id) => {
+ if (!id) {
+ onSelectDbs([])
+ attached.forEach((file) => onToggleLibraryFile?.(file))
+ return
+ }
+ if (id.startsWith(CONN_PREFIX)) {
+ const next = databases.find((item) => item.id === id.slice(CONN_PREFIX.length))
+ if (!next) return
+ const exists = selectedDbs.some((item) => item.id === next.id)
+ onSelectDbs(exists ? selectedDbs.filter((item) => item.id !== next.id) : [...selectedDbs, next])
+ return
+ }
+ if (id.startsWith(FILE_PREFIX) && onToggleLibraryFile) {
+ const file = dataFiles.find((item) => item.id === id.slice(FILE_PREFIX.length))
+ if (file) onToggleLibraryFile(file)
+ }
+ }}
+ leading={
+
+ }
+ trailing={
+ names.length > 0 ? (
+
+ {names.length}
+
+ ) : null
+ }
+ minMenuWidth={280}
+ emptyLabel={t('assistant.composer.dataEmpty')}
+ triggerClassName={toolbarChipClass(dataActive)}
+ footer={
+ onUploadNew ? (
+
+ ) : null
+ }
+ />
+ ({ value: item.id, label: item.name })),
+ ]}
+ onChange={(id) => onSelectKnowledge(knowledge.find((item) => item.id === id) || null)}
+ leading={
+
+ }
+ minMenuWidth={220}
+ emptyLabel={t('assistant.composer.knowledgeEmpty')}
+ triggerClassName={toolbarChipClass(Boolean(selectedKnowledge))}
+ trailing={
+ selectedKnowledge ? (
+ 1
+ ) : null
+ }
+ />
+
+ )
+}
diff --git a/src/features/assistant/input/SkillSelector.tsx b/src/features/assistant/input/SkillSelector.tsx
new file mode 100644
index 0000000..602e5e3
--- /dev/null
+++ b/src/features/assistant/input/SkillSelector.tsx
@@ -0,0 +1,463 @@
+import { ArrowUpLeft, Check, ChevronDown, LayoutGrid, Search, Sparkles } from 'lucide-react'
+import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
+import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { Link, useLocation, useNavigate } from 'react-router-dom'
+import { t } from '../../../i18n'
+import { selectMenuPanelClass } from '../../../components/ui/SelectMenu'
+import { ConfirmDialog } from '../../../components/ui/ConfirmDialog'
+import type { ResourceOption } from '../types/chat'
+import { ToolbarChipIcon, toolbarChipClass } from './toolbarChip'
+import type { SkillCategoryId } from '../../skills/catalog'
+import { CATEGORY_ICON, CATEGORY_TONE, skillIcon } from '../../skills/icons'
+import {
+ categoriesWithCounts,
+ matchesSkillQuery,
+ skillsForPicker,
+ type PresentedSkill,
+} from '../../skills/present'
+import { skillNeedsSetup } from '../../settings/types'
+import { skillSettingsPath } from '../../settings/api'
+
+type MenuPos = {
+ top: number
+ left: number
+ width: number
+ openUp: boolean
+ maxHeight: number
+}
+
+export function SkillSelector({
+ skills,
+ value,
+ onChange,
+}: {
+ skills: ResourceOption[]
+ value: ResourceOption | null
+ onChange: (skill: ResourceOption | null) => void
+}) {
+ const listId = useId()
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState('')
+ const [categoryId, setCategoryId] = useState('all')
+ const [pos, setPos] = useState(null)
+ const triggerRef = useRef(null)
+ const menuRef = useRef(null)
+ const searchRef = useRef(null)
+ const navigate = useNavigate()
+ const location = useLocation()
+ const [pending, setPending] = useState(null)
+
+ const presented = useMemo(() => skillsForPicker(skills), [skills])
+ const selected = presented.find((item) => item.id === value?.id) || null
+ const categories = useMemo(() => categoriesWithCounts(presented), [presented])
+ const visible = useMemo(() => {
+ return presented.filter((skill) => {
+ if (categoryId !== 'all' && skill.categoryId !== categoryId) return false
+ return matchesSkillQuery(skill, query)
+ })
+ }, [presented, categoryId, query])
+ const grouped = useMemo(() => {
+ if (categoryId !== 'all') {
+ const category = categories.find((item) => item.id === categoryId)
+ return [
+ {
+ id: categoryId,
+ title: category?.title || '',
+ blurb: category?.blurb || '',
+ items: visible,
+ },
+ ]
+ }
+ return categories
+ .map((category) => ({
+ ...category,
+ items: visible.filter((skill) => skill.categoryId === category.id),
+ }))
+ .filter((group) => group.items.length > 0)
+ }, [categories, categoryId, visible])
+
+ function updatePosition() {
+ if (!triggerRef.current) return
+ const rect = triggerRef.current.getBoundingClientRect()
+ const width = Math.min(780, Math.max(360, window.innerWidth - 24))
+ const spaceBelow = window.innerHeight - rect.bottom - 12
+ const spaceAbove = rect.top - 12
+ const openUp = spaceBelow < 300 && spaceAbove > spaceBelow
+ const maxHeight = Math.min(560, Math.max(300, openUp ? spaceAbove : spaceBelow))
+ const left = Math.min(Math.max(12, rect.right - width), window.innerWidth - width - 12)
+ setPos({
+ top: openUp ? rect.top - 6 : rect.bottom + 6,
+ left,
+ width,
+ openUp,
+ maxHeight,
+ })
+ }
+
+ useLayoutEffect(() => {
+ if (!open) return
+ updatePosition()
+ setQuery('')
+ setCategoryId('all')
+ }, [open])
+
+ useEffect(() => {
+ if (!open) return
+ function onPointerDown(event: MouseEvent) {
+ const target = event.target as Node
+ if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) {
+ setOpen(false)
+ }
+ }
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ setOpen(false)
+ triggerRef.current?.focus()
+ }
+ }
+ function onReposition() {
+ updatePosition()
+ }
+ document.addEventListener('mousedown', onPointerDown)
+ document.addEventListener('keydown', onKeyDown)
+ window.addEventListener('resize', onReposition)
+ window.addEventListener('scroll', onReposition, true)
+ return () => {
+ document.removeEventListener('mousedown', onPointerDown)
+ document.removeEventListener('keydown', onKeyDown)
+ window.removeEventListener('resize', onReposition)
+ window.removeEventListener('scroll', onReposition, true)
+ }
+ }, [open])
+
+ useEffect(() => {
+ if (!open) return
+ searchRef.current?.focus()
+ }, [open, pos])
+
+ function selectSkill(next: PresentedSkill | null) {
+ if (!next) {
+ onChange(null)
+ setOpen(false)
+ triggerRef.current?.focus()
+ return
+ }
+ const original = skills.find((item) => item.id === next.id) || {
+ id: next.id,
+ name: next.name,
+ description: next.description,
+ nameEn: next.nameEn,
+ category: next.category,
+ categoryId: next.categoryId,
+ domain: next.domain,
+ icon: next.icon,
+ }
+ if (skillNeedsSetup(original)) {
+ setPending(original)
+ setOpen(false)
+ return
+ }
+ onChange(original)
+ setOpen(false)
+ triggerRef.current?.focus()
+ }
+
+ function onTriggerKeyDown(event: ReactKeyboardEvent) {
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ setOpen(true)
+ }
+ }
+
+ const SelectedIcon = skillIcon(selected?.icon)
+ const triggerLabel = selected?.name || t('assistant.composer.skill')
+
+ const menu =
+ open && pos
+ ? createPortal(
+
+
+
+
+
+
+
+
+
مهارتهای کامیدسو
+
+ {presented.length} مهارت در {categories.length} حوزه تخصصی
+
+
+
+
setOpen(false)}
+ className="inline-flex shrink-0 items-center gap-1 rounded-full border border-line bg-white px-2.5 py-1 text-[11px] font-medium text-ink-muted transition hover:border-accent/30 hover:text-accent"
+ >
+ کاتالوگ کامل
+
+
+
+
+
+ setQuery(event.target.value)}
+ placeholder="جستوجوی مهارت، حوزه یا مسئله…"
+ className="h-10 w-full rounded-xl border border-line bg-white pr-9 pl-3 text-sm text-ink outline-none transition placeholder:text-ink-subtle focus:border-accent focus:ring-2 focus:ring-accent/15"
+ />
+
+
+
+
+
+
+
+ setCategoryId('all')} />
+ {categories.map((category) => (
+ setCategoryId(category.id)}
+ />
+ ))}
+
+
+ {visible.length === 0 ? (
+
+
+
{t('assistant.composer.skillEmpty')}
+
+ ) : (
+
+ {grouped.map((group) => {
+ const tone =
+ group.id !== 'all' && group.id in CATEGORY_TONE
+ ? CATEGORY_TONE[group.id as SkillCategoryId]
+ : CATEGORY_TONE.decision
+ return (
+
+
+
+
{group.title}
+ {group.items.length}
+
+ {categoryId !== 'all' && group.blurb ? (
+ {group.blurb}
+ ) : null}
+
+ {group.items.map((skill) => (
+ selectSkill(skill)}
+ />
+ ))}
+
+
+ )
+ })}
+
+ )}
+
+
+
+
,
+ document.body,
+ )
+ : null
+
+ return (
+
+
+ {menu}
+ {
+ if (!pending) return
+ const returnTo = `${location.pathname}${location.search}`
+ navigate(skillSettingsPath(pending.id, returnTo || '/assistant'))
+ setPending(null)
+ }}
+ onCancel={() => {
+ setPending(null)
+ triggerRef.current?.focus()
+ }}
+ />
+
+ )
+}
+
+function CategoryButton({
+ active,
+ label,
+ count,
+ tone,
+ onClick,
+}: {
+ active: boolean
+ label: string
+ count: number
+ tone?: SkillCategoryId
+ onClick: () => void
+}) {
+ const palette = tone ? CATEGORY_TONE[tone] : null
+ const Icon = tone ? CATEGORY_ICON[tone] : LayoutGrid
+ return (
+
+ )
+}
+
+function MiniChip({
+ active,
+ label,
+ tone,
+ onClick,
+}: {
+ active: boolean
+ label: string
+ tone?: SkillCategoryId
+ onClick: () => void
+}) {
+ const palette = tone ? CATEGORY_TONE[tone] : null
+ return (
+
+ )
+}
+
+function SkillPickCard({
+ skill,
+ active,
+ onSelect,
+}: {
+ skill: PresentedSkill
+ active: boolean
+ onSelect: () => void
+}) {
+ const Icon = skillIcon(skill.icon)
+ const tone = skill.categoryId !== 'other' ? CATEGORY_TONE[skill.categoryId] : CATEGORY_TONE.decision
+ return (
+
+ )
+}
diff --git a/src/features/assistant/input/ToolsBar.test.tsx b/src/features/assistant/input/ToolsBar.test.tsx
new file mode 100644
index 0000000..a41caec
--- /dev/null
+++ b/src/features/assistant/input/ToolsBar.test.tsx
@@ -0,0 +1,122 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+import { ToolsBar } from './ToolsBar'
+
+describe('ToolsBar', () => {
+ it('shows labeled resource chips and hides the model switcher', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'داده' })).toHaveTextContent('داده')
+ expect(screen.queryByRole('button', { name: 'فایلها' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'پایگاه داده' })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'دانش' })).toHaveTextContent('دانش')
+ expect(screen.getByRole('button', { name: 'مهارت' })).toHaveTextContent('مهارت')
+ expect(screen.queryByRole('button', { name: 'مدل' })).not.toBeInTheDocument()
+ expect(screen.queryByText('خودکار')).not.toBeInTheDocument()
+ })
+
+ it('keeps the skill picker when files are attached instead of locking data analysis', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'مهارت' })).toBeInTheDocument()
+ expect(screen.queryByText('تحلیل داده')).not.toBeInTheDocument()
+ })
+
+ it('lets the user pick Fast, Medium, or Deep for the next request', async () => {
+ const user = userEvent.setup()
+ const onReasoningModeChange = vi.fn()
+ render(
+
+
+ ,
+ )
+
+ const depth = screen.getByRole('button', { name: 'سریع' }).parentElement
+ expect(depth).toHaveClass('ms-auto')
+ expect(screen.getByRole('button', { name: 'سریع' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'متوسط' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'عمیق' })).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'متوسط' }))
+ expect(onReasoningModeChange).toHaveBeenCalledWith('medium')
+ await user.click(screen.getByRole('button', { name: 'عمیق' }))
+ expect(onReasoningModeChange).toHaveBeenCalledWith('deep')
+ })
+
+ it('toggles report mode for the next request', async () => {
+ const user = userEvent.setup()
+ const onReportModeChange = vi.fn()
+ render(
+
+
+ ,
+ )
+
+ const toggle = screen.getByRole('button', { name: 'گزارش' })
+ expect(toggle).toHaveAttribute('aria-pressed', 'false')
+ await user.click(toggle)
+ expect(onReportModeChange).toHaveBeenCalledWith(true)
+ })
+})
diff --git a/src/features/assistant/input/ToolsBar.tsx b/src/features/assistant/input/ToolsBar.tsx
new file mode 100644
index 0000000..55d2f72
--- /dev/null
+++ b/src/features/assistant/input/ToolsBar.tsx
@@ -0,0 +1,102 @@
+import { FileBarChart } from 'lucide-react'
+import { ResourcePicker } from './ResourcePicker'
+import { SkillSelector } from './SkillSelector'
+import { ToolbarChipIcon, toolbarChipClass } from './toolbarChip'
+import { t } from '../../../i18n'
+import type { ResourceOption } from '../types/chat'
+import type { KamidsoFile } from '../session-files'
+
+export function ToolsBar({
+ databases,
+ knowledge,
+ skills,
+ selectedDbs,
+ selectedKnowledge,
+ selectedSkill,
+ onSelectDbs,
+ onSelectKnowledge,
+ onSelectSkill,
+ reportMode = false,
+ onReportModeChange,
+ reasoningMode = 'fast',
+ onReasoningModeChange,
+ libraryFiles = [],
+ attachedFileIds = [],
+ onToggleLibraryFile,
+ onUploadNew,
+}: {
+ databases: ResourceOption[]
+ knowledge: ResourceOption[]
+ skills: ResourceOption[]
+ selectedDbs: ResourceOption[]
+ selectedKnowledge: ResourceOption | null
+ selectedSkill: ResourceOption | null
+ onSelectDbs: (items: ResourceOption[]) => void
+ onSelectKnowledge: (item: ResourceOption | null) => void
+ onSelectSkill: (item: ResourceOption | null) => void
+ reportMode?: boolean
+ onReportModeChange?: (enabled: boolean) => void
+ reasoningMode?: 'fast' | 'medium' | 'deep'
+ onReasoningModeChange?: (mode: 'fast' | 'medium' | 'deep') => void
+ libraryFiles?: KamidsoFile[]
+ attachedFileIds?: readonly string[]
+ onToggleLibraryFile?: (file: KamidsoFile) => void
+ onUploadNew?: () => void
+}) {
+ return (
+
+
+
+ {onReportModeChange ? (
+
+ ) : null}
+ {onReasoningModeChange ? (
+
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/input/toolbarChip.tsx b/src/features/assistant/input/toolbarChip.tsx
new file mode 100644
index 0000000..0a9e966
--- /dev/null
+++ b/src/features/assistant/input/toolbarChip.tsx
@@ -0,0 +1,31 @@
+import type { LucideIcon } from 'lucide-react'
+
+const TONE = {
+ muted: 'bg-white text-ink-muted ring-1 ring-inset ring-line/80',
+ database: 'bg-success-soft text-success',
+ files: 'bg-accent-soft text-accent',
+ knowledge: 'bg-warn-soft text-warn',
+ skill: 'bg-info-soft text-info',
+} as const
+
+export function ToolbarChipIcon({
+ icon: Icon,
+ tone = 'muted',
+}: {
+ icon: LucideIcon
+ tone?: keyof typeof TONE
+}) {
+ return (
+
+
+
+ )
+}
+
+export function toolbarChipClass(active: boolean) {
+ return active
+ ? 'shrink-0 max-w-[16rem] border-accent/25 bg-white text-ink shadow-[0_1px_2px_rgba(23,32,51,0.05)] hover:border-accent/40'
+ : 'shrink-0 border-line/80 bg-surface-subtle/90 text-ink-muted hover:border-accent/30 hover:bg-white hover:text-ink'
+}
diff --git a/src/features/assistant/lib/events.ts b/src/features/assistant/lib/events.ts
new file mode 100644
index 0000000..45cbef6
--- /dev/null
+++ b/src/features/assistant/lib/events.ts
@@ -0,0 +1,8 @@
+export function unwrapWorkspaceEvent>(payload: T): T {
+ if (payload && typeof payload === 'object' && payload.workspace && typeof payload.workspace === 'object') {
+ const { workspace, ...canonical } = payload
+ // Kamidso canonical fields (e.g. localized title) must win over raw DBGPT workspace copies.
+ return { ...(workspace as Record), ...canonical } as T
+ }
+ return payload
+}
diff --git a/src/features/assistant/lib/final.test.ts b/src/features/assistant/lib/final.test.ts
new file mode 100644
index 0000000..5a5cd05
--- /dev/null
+++ b/src/features/assistant/lib/final.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest'
+import { cleanFinalContent, extractTerminateResult, looksTruncated } from './final'
+
+describe('cleanFinalContent', () => {
+ it('unwraps single-line ReAct terminate envelopes', () => {
+ const raw =
+ 'Thought: تمام دادههای لازم جمع شد. Action: terminate Action Input: {"result": "## تحلیل فروش\\n\\nجزئیات"}'
+ expect(cleanFinalContent(raw)).toBe('## تحلیل فروش\n\nجزئیات')
+ })
+
+ it('leaves ordinary markdown alone', () => {
+ expect(cleanFinalContent('## خلاصه\n\nمتن عادی')).toBe('## خلاصه\n\nمتن عادی')
+ })
+
+ it('extractTerminateResult handles truncated JSON', () => {
+ expect(extractTerminateResult('Action Input: {"result": "## شروع\\nادامه')).toBe('## شروع\nادامه')
+ })
+})
+
+describe('looksTruncated', () => {
+ it('flags a cover page that dies on heading 1', () => {
+ const text = [
+ 'گزارش ریسک ریزش مشتریان (Churn Risk) — پلتفرم معاملات طلا و نقره',
+ 'تاریخ تحلیل: سهشنبه ۱۴ مهر ۱۴۰۵ | دادهها: users، transactions',
+ '۱. خ',
+ ].join('\n')
+ expect(looksTruncated(text)).toBe(true)
+ })
+
+ it('does not flag a finished answer that ends with a later bullet', () => {
+ expect(
+ looksTruncated(
+ 'کاربران در معرض ریزش ۱۰ نفر هستند.\n\n۵. اقدام فوری: تماس با ده کاربر برتر تا پایان هفته.',
+ ),
+ ).toBe(false)
+ })
+})
diff --git a/src/features/assistant/lib/final.ts b/src/features/assistant/lib/final.ts
new file mode 100644
index 0000000..8509196
--- /dev/null
+++ b/src/features/assistant/lib/final.ts
@@ -0,0 +1,99 @@
+export type AgentCitation = {
+ index: number
+ id: string
+ sourceName: string
+ excerpt: string
+}
+
+export function decodeFinalEvent(payload: Record): { content: string; citations: AgentCitation[] } {
+ const content =
+ (typeof payload.content === 'string' && payload.content) ||
+ (typeof payload.answer === 'string' && payload.answer) ||
+ (typeof payload.text === 'string' && payload.text) ||
+ ''
+ const rawCitations = Array.isArray(payload.citations) ? payload.citations : []
+ const citations: AgentCitation[] = rawCitations.map((item, index) => {
+ const c = item as Record
+ return {
+ index: typeof c.index === 'number' ? c.index : index + 1,
+ id: String(c.id ?? index + 1),
+ sourceName: String(c.source_name ?? c.sourceName ?? 'منبع'),
+ excerpt: String(c.excerpt ?? c.content ?? ''),
+ }
+ })
+ return { content, citations }
+}
+
+/** Decode JSON string escapes inside a terminate Action Input result. */
+function unescapeJsonString(raw: string): string {
+ let out = ''
+ for (let i = 0; i < raw.length; i += 1) {
+ const ch = raw[i]
+ if (ch === '\\' && i + 1 < raw.length) {
+ const nxt = raw[i + 1]
+ const map: Record = {
+ n: '\n',
+ t: '\t',
+ r: '\r',
+ '"': '"',
+ '\\': '\\',
+ '/': '/',
+ }
+ out += map[nxt] ?? nxt
+ i += 1
+ continue
+ }
+ out += ch
+ }
+ return out
+}
+
+/**
+ * Extract user-facing answer from a leaked ReAct terminate envelope.
+ * Backend should already unwrap this; this is a UI safety net.
+ */
+export function extractTerminateResult(text: string): string | null {
+ if (!text) return null
+ const match = /"(?:result|output)"\s*:\s*"/.exec(text)
+ if (!match || match.index == null) return null
+ const start = match.index + match[0].length
+ let end = start
+ while (end < text.length) {
+ if (text[end] === '\\' && end + 1 < text.length) {
+ end += 2
+ continue
+ }
+ if (text[end] === '"') break
+ end += 1
+ }
+ const body = text.slice(start, end)
+ return body.trim() ? unescapeJsonString(body) : null
+}
+
+/** True when the visible answer looks cut off mid-section (token/JSON cap). */
+export function looksTruncated(text: string): boolean {
+ const trimmed = text.trim()
+ if (trimmed.length < 80) return false
+ const heading1 = trimmed.match(/(?:^|\n)\s*(?:1|۱)[\.\-–:]\s*(\S.*)?$/)
+ if (heading1 && (heading1[1] || '').trim().length < 40) return true
+ if (/(?:^|\n)#{1,6}\s+\S.{0,80}$/.test(trimmed) && trimmed.split('\n').filter(Boolean).length >= 3) {
+ return true
+ }
+ return false
+}
+
+export function cleanFinalContent(text: string): string {
+ let cleaned = text.replace(/[\s\S]*?<\/references>/gi, '').trim()
+ if (
+ /(?:^|\s)Thought\s*:/i.test(cleaned) ||
+ /(?:^|\s)Action\s*Input\s*:/i.test(cleaned) ||
+ /(?:^|\s)Action\s*:\s*terminate\b/i.test(cleaned)
+ ) {
+ const salvaged = extractTerminateResult(cleaned)
+ if (salvaged) return salvaged.trim()
+ cleaned = cleaned
+ .replace(/(?:^|\n)\s*(?:Thought|Action|Action Input|Observation|Phase)\s*:\s*/gi, '\n')
+ .trim()
+ }
+ return cleaned
+}
diff --git a/src/features/assistant/questions/QuestionDock.tsx b/src/features/assistant/questions/QuestionDock.tsx
new file mode 100644
index 0000000..f9df035
--- /dev/null
+++ b/src/features/assistant/questions/QuestionDock.tsx
@@ -0,0 +1,83 @@
+import { useEffect, useState } from 'react'
+import { Button } from '../../../components/Button'
+import type { PendingQuestion } from '../../../contracts/run'
+import { t } from '../../../i18n'
+
+export function QuestionDock({
+ question,
+ onReply,
+ onReject,
+}: {
+ question: PendingQuestion
+ onReply: (text: string) => void
+ onReject: () => void
+}) {
+ const hasOptions = Boolean(question.options?.length)
+ const [text, setText] = useState('')
+ const [showOther, setShowOther] = useState(!hasOptions)
+
+ useEffect(() => {
+ setText('')
+ setShowOther(!question.options?.length)
+ }, [question.id, question.options?.length])
+
+ const submitText = () => {
+ const value = text.trim()
+ if (!value) return
+ onReply(value)
+ }
+
+ return (
+
+
{t('assistant.question.title')}
+
{question.question}
+
+ {hasOptions ? (
+
+ {question.options!.map((opt) => (
+
+ ))}
+ {!showOther ? (
+
+ ) : null}
+
+ ) : null}
+
+ {showOther ? (
+
+
setText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ submitText()
+ }
+ }}
+ className="w-full rounded-lg border border-line px-3 py-2 text-sm"
+ placeholder={t('assistant.question.placeholder')}
+ />
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
diff --git a/src/features/assistant/report/downloadReportPdf.test.ts b/src/features/assistant/report/downloadReportPdf.test.ts
new file mode 100644
index 0000000..9c2f49b
--- /dev/null
+++ b/src/features/assistant/report/downloadReportPdf.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+import { reportTitleFromHtml, withPrintStyles } from './downloadReportPdf'
+
+describe('withPrintStyles', () => {
+ it('keeps the report and adds a print stylesheet plus title', () => {
+ const html = "گزارش
"
+ const prepared = withPrintStyles(html, 'فروش')
+ expect(prepared).toContain('گزارش
')
+ expect(prepared).toContain('data-kamidso-print')
+ expect(prepared).toContain('فروش')
+ expect(prepared).toContain("dir='rtl'")
+ })
+
+ it('does not replace an existing title', () => {
+ const html = 'اصلی'
+ const prepared = withPrintStyles(html, 'دیگر')
+ expect(prepared).toContain('اصلی')
+ expect(prepared).not.toContain('دیگر')
+ })
+
+ it('reads the document title', () => {
+ expect(reportTitleFromHtml('فروش ماه', 'گزارش')).toBe(
+ 'فروش ماه',
+ )
+ expect(reportTitleFromHtml('بدون عنوان
', 'گزارش')).toBe('گزارش')
+ })
+})
diff --git a/src/features/assistant/report/downloadReportPdf.ts b/src/features/assistant/report/downloadReportPdf.ts
new file mode 100644
index 0000000..ebf22ab
--- /dev/null
+++ b/src/features/assistant/report/downloadReportPdf.ts
@@ -0,0 +1,85 @@
+const PRINT_STYLE = ``
+
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+}
+
+export function reportTitleFromHtml(html: string, fallback: string): string {
+ const match = html.match(/]*>([\s\S]*?)<\/title>/i)
+ const text = match?.[1]?.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
+ return text || fallback
+}
+
+/** Keep the report document and add only print color + a filename title. */
+export function withPrintStyles(html: string, title: string): string {
+ const safeTitle = escapeHtml(title.trim() || 'گزارش')
+ let doc = html.trim()
+ if (!doc) {
+ return `${safeTitle}${PRINT_STYLE}`
+ }
+ if (!/]/i.test(doc)) {
+ if (/]/i.test(doc)) {
+ doc = doc.replace(/]*)>/i, `${safeTitle}`)
+ } else if (/]/i.test(doc)) {
+ doc = doc.replace(/]*)>/i, `${safeTitle}`)
+ } else {
+ doc = `${safeTitle}${doc}`
+ }
+ }
+ if (/<\/head>/i.test(doc)) {
+ return doc.replace(/<\/head>/i, `${PRINT_STYLE}`)
+ }
+ return `${PRINT_STYLE}${doc}`
+}
+
+function cleanupLater(iframe: HTMLIFrameElement, url: string) {
+ window.setTimeout(() => {
+ iframe.remove()
+ URL.revokeObjectURL(url)
+ }, 60_000)
+}
+
+/**
+ * Open the browser print dialog for this exact HTML document.
+ * Save as PDF there — the page is the report, without the app chrome.
+ */
+export function downloadReportPdf(html: string, title: string) {
+ const prepared = withPrintStyles(html, title)
+ const blob = new Blob([prepared], { type: 'text/html;charset=utf-8' })
+ const url = URL.createObjectURL(blob)
+ const iframe = document.createElement('iframe')
+ iframe.setAttribute('aria-hidden', 'true')
+ iframe.title = 'report-pdf'
+ iframe.style.cssText =
+ 'position:fixed;left:0;top:0;width:960px;height:1280px;opacity:0;pointer-events:none;border:0;z-index:-1'
+ iframe.src = url
+ iframe.onload = () => {
+ const win = iframe.contentWindow
+ try {
+ win?.focus()
+ win?.print()
+ } catch {
+ iframe.remove()
+ URL.revokeObjectURL(url)
+ return
+ }
+ try {
+ win?.addEventListener('afterprint', () => {
+ iframe.remove()
+ URL.revokeObjectURL(url)
+ })
+ } catch {
+ cleanupLater(iframe, url)
+ return
+ }
+ cleanupLater(iframe, url)
+ }
+ document.body.appendChild(iframe)
+}
diff --git a/src/features/assistant/report/extractReport.test.ts b/src/features/assistant/report/extractReport.test.ts
new file mode 100644
index 0000000..62f706a
--- /dev/null
+++ b/src/features/assistant/report/extractReport.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import {
+ extractHtmlFromArtifacts,
+ extractHtmlFromOutputList,
+ resolveTurnHtml,
+} from './extractReport'
+
+const sampleHtml = "گزارش
"
+
+describe('extractHtml', () => {
+ it('finds html output in step outputs', () => {
+ const found = extractHtmlFromOutputList([
+ { output_type: 'text', content: 'ok' },
+ { output_type: 'html', content: sampleHtml },
+ ])
+ expect(found).toBe(sampleHtml)
+ })
+
+ it('finds html artifact', () => {
+ const found = extractHtmlFromArtifacts([
+ { id: '1', type: 'html', name: 'گزارش', content: sampleHtml },
+ ])
+ expect(found).toContain('گزارش')
+ })
+
+ it('prefers htmlByViewId', () => {
+ const resolved = resolveTurnHtml({
+ viewId: 'view-1',
+ htmlByViewId: { 'view-1': sampleHtml },
+ })
+ expect(resolved).toBe(sampleHtml)
+ })
+})
diff --git a/src/features/assistant/report/extractReport.ts b/src/features/assistant/report/extractReport.ts
new file mode 100644
index 0000000..99ee471
--- /dev/null
+++ b/src/features/assistant/report/extractReport.ts
@@ -0,0 +1,65 @@
+import type { KamidsoArtifact } from '../../../contracts/run'
+import type { ExecutionOutput, ExecutionState } from '../types/chat'
+
+function asHtml(content: unknown): string | null {
+ if (typeof content === 'string' && content.trim()) return content
+ if (content && typeof content === 'object' && 'html' in (content as object)) {
+ const html = String((content as { html?: unknown }).html ?? '')
+ return html.trim() ? html : null
+ }
+ return null
+}
+
+export function extractHtmlFromOutputs(outputs: Record): string | null {
+ for (const stepId of Object.keys(outputs)) {
+ const found = extractHtmlFromOutputList(outputs[stepId])
+ if (found) return found
+ }
+ return null
+}
+
+export function extractHtmlFromOutputList(outputs?: ExecutionOutput[]): string | null {
+ if (!outputs?.length) return null
+ for (let i = outputs.length - 1; i >= 0; i -= 1) {
+ const item = outputs[i]
+ if (item.output_type === 'html') {
+ const html = asHtml(item.content)
+ if (html) return html
+ }
+ }
+ return null
+}
+
+export function extractHtmlFromExecution(execution?: ExecutionState): string | null {
+ if (!execution) return null
+ return extractHtmlFromOutputs(execution.outputs)
+}
+
+export function extractHtmlFromArtifacts(artifacts?: KamidsoArtifact[]): string | null {
+ if (!artifacts?.length) return null
+ for (let i = artifacts.length - 1; i >= 0; i -= 1) {
+ const artifact = artifacts[i]
+ if (artifact.type === 'html') {
+ const html = asHtml(artifact.content)
+ if (html) return html
+ }
+ }
+ return null
+}
+
+export function resolveTurnHtml({
+ execution,
+ artifacts,
+ htmlByViewId,
+ viewId,
+}: {
+ execution?: ExecutionState
+ artifacts?: KamidsoArtifact[]
+ htmlByViewId?: Record
+ viewId?: string
+}): string | null {
+ if (viewId && htmlByViewId?.[viewId]) {
+ return htmlByViewId[viewId]
+ }
+ return extractHtmlFromExecution(execution) ?? extractHtmlFromArtifacts(artifacts)
+}
diff --git a/src/features/assistant/report/formatValue.test.ts b/src/features/assistant/report/formatValue.test.ts
new file mode 100644
index 0000000..3b263bb
--- /dev/null
+++ b/src/features/assistant/report/formatValue.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { displayLabel, formatCellValue, isNumericColumn, parseNumeric, splitValueAndUnit } from './formatValue'
+
+describe('report value formatting', () => {
+ it('parses grouped and plain numbers', () => {
+ expect(parseNumeric(4484808201)).toBe(4484808201)
+ expect(parseNumeric('4,484,808,201')).toBe(4484808201)
+ expect(parseNumeric('۴٬۴۸۴٬۸۰۸٬۲۰۱')).toBe(4484808201)
+ expect(parseNumeric('789ردیف')).toBe(789)
+ expect(parseNumeric('1404/01/01 – 1404/12/26')).toBeNull()
+ })
+
+ it('keeps a gap between amount and unit', () => {
+ expect(splitValueAndUnit('4484808201', 'ریال')).toEqual({
+ amount: '4,484,808,201',
+ unit: 'ریال',
+ })
+ expect(splitValueAndUnit('4,484,808,201ریال')).toEqual({
+ amount: '4,484,808,201',
+ unit: 'ریال',
+ })
+ })
+
+ it('formats table cells and Jalali month labels', () => {
+ expect(formatCellValue(580900500)).toBe('580,900,500')
+ expect(displayLabel('01', 'ماه')).toBe('فروردین')
+ expect(displayLabel('12', 'ماه')).toBe('اسفند')
+ expect(isNumericColumn('ماه', ['01', '02'])).toBe(false)
+ expect(isNumericColumn('مقدار (ریال)', [580900500, 298033700])).toBe(true)
+ })
+})
diff --git a/src/features/assistant/report/formatValue.ts b/src/features/assistant/report/formatValue.ts
new file mode 100644
index 0000000..68bc45a
--- /dev/null
+++ b/src/features/assistant/report/formatValue.ts
@@ -0,0 +1,94 @@
+const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
+const LATIN_DIGITS = '0123456789'
+const JALALI_MONTHS = [
+ 'فروردین',
+ 'اردیبهشت',
+ 'خرداد',
+ 'تیر',
+ 'مرداد',
+ 'شهریور',
+ 'مهر',
+ 'آبان',
+ 'آذر',
+ 'دی',
+ 'بهمن',
+ 'اسفند',
+]
+
+export function toLatinDigits(value: string): string {
+ return value.replace(/[۰-۹]/g, (digit) => LATIN_DIGITS[PERSIAN_DIGITS.indexOf(digit)] ?? digit)
+}
+
+export function parseNumeric(value: unknown): number | null {
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
+ if (typeof value !== 'string') return null
+ let text = toLatinDigits(value).trim()
+ if (!text || text === '—' || text === '-') return null
+ text = text.replace(/[٬،]/g, ',').replace(/\s/g, '')
+ const match = text.match(/^([+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?)(.*)$/)
+ if (!match) return null
+ if (/\d/.test(match[2])) return null
+ const parsed = Number(match[1].replace(/,/g, ''))
+ return Number.isFinite(parsed) ? parsed : null
+}
+
+export function formatNumber(value: number): string {
+ const abs = Math.abs(value)
+ const fractionDigits = Number.isInteger(value) || abs >= 100 ? 0 : abs >= 1 ? 2 : 4
+ return new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: 0,
+ }).format(value)
+}
+
+export function formatCellValue(value: unknown): string {
+ if (value == null || (typeof value === 'number' && Number.isNaN(value))) return '—'
+ if (typeof value === 'object') return JSON.stringify(value)
+ const numeric = parseNumeric(value)
+ if (numeric == null) return String(value)
+ const formatted = formatNumber(numeric)
+ if (typeof value === 'string') {
+ const latin = toLatinDigits(value).trim()
+ const suffix = latin.replace(/^[+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?/, '').trim()
+ if (suffix && !/^[,\s]+$/.test(suffix)) return `${formatted} ${suffix}`
+ }
+ return formatted
+}
+
+export function splitValueAndUnit(value: unknown, unit?: string): { amount: string; unit?: string } {
+ if (value == null || value === '') return { amount: '—', unit }
+ const numeric = parseNumeric(value)
+ if (numeric == null) {
+ return { amount: String(value), unit: unit?.trim() || undefined }
+ }
+ let extra: string | undefined
+ if (typeof value === 'string') {
+ const latin = toLatinDigits(value).trim()
+ extra = latin.replace(/^[+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?/, '').trim() || undefined
+ }
+ const resolved = (unit?.trim() || extra || '').trim() || undefined
+ return { amount: formatNumber(numeric), unit: resolved }
+}
+
+export function jalaliMonthLabel(value: unknown, columnTitle?: string): string | null {
+ const title = String(columnTitle || '')
+ if (!/ماه|month/i.test(title)) return null
+ const numeric = parseNumeric(value)
+ if (numeric == null || numeric < 1 || numeric > 12 || !Number.isInteger(numeric)) return null
+ return JALALI_MONTHS[numeric - 1] ?? null
+}
+
+export function displayLabel(value: unknown, columnTitle?: string): string {
+ return jalaliMonthLabel(value, columnTitle) || formatCellValue(value)
+}
+
+export function isMostlyNumeric(values: unknown[]): boolean {
+ if (!values.length) return false
+ const hits = values.filter((value) => parseNumeric(value) != null).length
+ return hits / values.length >= 0.6
+}
+
+export function isNumericColumn(title: string, values: unknown[]): boolean {
+ if (/ماه|month|نام|شرح|جهت|عنوان|label|category|نوع|وضعیت/i.test(title)) return false
+ return isMostlyNumeric(values)
+}
diff --git a/src/features/assistant/session-files/AttachmentMessageCards.tsx b/src/features/assistant/session-files/AttachmentMessageCards.tsx
new file mode 100644
index 0000000..0d2da07
--- /dev/null
+++ b/src/features/assistant/session-files/AttachmentMessageCards.tsx
@@ -0,0 +1,61 @@
+import { FileSpreadsheet, FileText, Image as ImageIcon } from 'lucide-react'
+import type { SessionFileSnapshot } from './types'
+
+function fileIcon(name: string) {
+ const ext = name.split('.').pop()?.toLowerCase() ?? ''
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return ImageIcon
+ if (['csv', 'xlsx', 'xls'].includes(ext)) return FileSpreadsheet
+ return FileText
+}
+
+function fileTone(name: string) {
+ const ext = name.split('.').pop()?.toLowerCase() ?? ''
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) {
+ return 'bg-violet-50 text-violet-600'
+ }
+ if (['csv', 'xlsx', 'xls'].includes(ext)) {
+ return 'bg-emerald-50 text-emerald-700'
+ }
+ return 'bg-slate-100 text-slate-600'
+}
+
+function formatBytes(bytes: number) {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+export default function AttachmentMessageCards({
+ files,
+ onPreview,
+}: {
+ files: readonly SessionFileSnapshot[]
+ onPreview?: (file: SessionFileSnapshot) => void
+}) {
+ if (!files.length) return null
+ return (
+
+ {files.map((file) => {
+ const Icon = fileIcon(file.name)
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/features/assistant/session-files/AttachmentMessageGroup.tsx b/src/features/assistant/session-files/AttachmentMessageGroup.tsx
new file mode 100644
index 0000000..2c1d70f
--- /dev/null
+++ b/src/features/assistant/session-files/AttachmentMessageGroup.tsx
@@ -0,0 +1,12 @@
+import AttachmentMessageCards from './AttachmentMessageCards'
+import type { SessionFileSnapshot } from './types'
+
+export default function AttachmentMessageGroup({
+ files,
+ onPreview,
+}: {
+ files: readonly SessionFileSnapshot[]
+ onPreview?: (file: SessionFileSnapshot) => void
+}) {
+ return
+}
diff --git a/src/features/assistant/session-files/AttachmentPreview.tsx b/src/features/assistant/session-files/AttachmentPreview.tsx
new file mode 100644
index 0000000..cf4c816
--- /dev/null
+++ b/src/features/assistant/session-files/AttachmentPreview.tsx
@@ -0,0 +1,90 @@
+import { Loader2, X } from 'lucide-react'
+import type { SessionFilePreviewSnapshot } from './types'
+
+export function AttachmentPreviewCloseButton({ onClick }: { onClick: () => void }) {
+ return (
+
+ )
+}
+
+export function AttachmentPreviewPanelTitle({ name }: { name: string }) {
+ return {name}
+}
+
+export default function AttachmentPreview({
+ snapshot,
+ loading,
+ error,
+}: {
+ snapshot: SessionFilePreviewSnapshot | null
+ loading?: boolean
+ error?: string | null
+}) {
+ if (loading) {
+ return (
+
+
+ در حال بارگذاری پیشنمایش…
+
+ )
+ }
+ if (error) {
+ return {error}
+ }
+ if (!snapshot) return null
+
+ const preview = snapshot.preview || {}
+ const kind = snapshot.kind || preview.kind
+
+ if (kind === 'table' && Array.isArray(preview.columns) && Array.isArray(preview.rows)) {
+ const columns = preview.columns as string[]
+ const rows = preview.rows as unknown[][]
+ return (
+
+
+
+
+ {columns.map((col) => (
+ |
+ {col}
+ |
+ ))}
+
+
+
+ {rows.slice(0, 100).map((row, i) => (
+
+ {columns.map((_, j) => (
+ |
+ {String(row[j] ?? '')}
+ |
+ ))}
+
+ ))}
+
+
+ {rows.length > 100 ? (
+
نمایش ۱۰۰ سطر اول از {rows.length}
+ ) : null}
+
+ )
+ }
+
+ const text =
+ (typeof preview.text === 'string' && preview.text) ||
+ (typeof preview.content === 'string' && preview.content) ||
+ JSON.stringify(preview, null, 2)
+
+ return (
+
+ {text}
+
+ )
+}
diff --git a/src/features/assistant/session-files/AttachmentRail.test.tsx b/src/features/assistant/session-files/AttachmentRail.test.tsx
new file mode 100644
index 0000000..5d017da
--- /dev/null
+++ b/src/features/assistant/session-files/AttachmentRail.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import AttachmentRail from './AttachmentRail'
+
+describe('AttachmentRail', () => {
+ it('renders selected databases and knowledge next to files', () => {
+ const onRemoveDatabase = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.getByText('SQLite')).toBeInTheDocument()
+ expect(screen.getByText('طلابان')).toBeInTheDocument()
+ expect(screen.getByText('نیتریمو')).toBeInTheDocument()
+ expect(screen.getByText('فضای دانش')).toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/session-files/AttachmentRail.tsx b/src/features/assistant/session-files/AttachmentRail.tsx
new file mode 100644
index 0000000..84d627e
--- /dev/null
+++ b/src/features/assistant/session-files/AttachmentRail.tsx
@@ -0,0 +1,169 @@
+import { AlertCircle, BookOpen, CheckCircle2, Database, Loader2, Paperclip, RefreshCw, X } from 'lucide-react'
+import type { ReactNode } from 'react'
+import type { DraftFile, LegacyServerFile } from './types'
+import { formatBytes, toLegacyRailItem, toRailItems } from './attachment-view-model'
+import { t } from '../../../i18n'
+
+export type RailDatabase = { id: string; name: string; type?: string }
+export type RailKnowledge = { id: string; name: string }
+
+export interface AttachmentRailProps {
+ drafts: readonly DraftFile[]
+ legacyFiles?: readonly LegacyServerFile[]
+ databases?: readonly RailDatabase[]
+ knowledge?: RailKnowledge | null
+ onRemove?: (clientId: string) => void
+ onRetry?: (clientId: string) => void
+ onRemoveDatabase?: (id: string) => void
+ onRemoveKnowledge?: () => void
+ addControl?: ReactNode
+ className?: string
+}
+
+export function AttachmentRailAddButton({ onClick }: { onClick: () => void }) {
+ return (
+
+ )
+}
+
+export default function AttachmentRail({
+ drafts,
+ legacyFiles = [],
+ databases = [],
+ knowledge = null,
+ onRemove,
+ onRetry,
+ onRemoveDatabase,
+ onRemoveKnowledge,
+ addControl,
+ className = '',
+}: AttachmentRailProps) {
+ const draftItems = toRailItems(drafts)
+ const legacyItems = legacyFiles.map(toLegacyRailItem)
+ const hasKnowledge = Boolean(knowledge?.name)
+ if (
+ draftItems.length === 0 &&
+ legacyItems.length === 0 &&
+ databases.length === 0 &&
+ !hasKnowledge &&
+ !addControl
+ ) {
+ return null
+ }
+
+ return (
+
+ {databases.map((item) => (
+
+
+
+
+
+
{item.name}
+
{item.type || t('assistant.composer.database')}
+
+ {onRemoveDatabase ? (
+
+ ) : null}
+
+ ))}
+ {hasKnowledge ? (
+
+
+
+
+
+
{knowledge!.name}
+
{t('assistant.panel.knowledgeHint')}
+
+ {onRemoveKnowledge ? (
+
+ ) : null}
+
+ ) : null}
+ {legacyItems.map((item) => (
+
+
+
+
+
+
{item.name}
+
{formatBytes(item.size)} · سرور
+
+
+ ))}
+ {draftItems.map((item) => {
+ const failed = item.uploadStatus === 'failed' || item.uploadStatus === 'blocked'
+ const uploading = item.uploadStatus === 'uploading' || item.uploadStatus === 'queued'
+ const done = item.uploadStatus === 'done'
+ return (
+
+
+ {uploading ? (
+
+ ) : failed ? (
+
+ ) : done ? (
+
+ ) : (
+
+ )}
+
+
+
{item.name}
+
+ {item.size ? formatBytes(item.size) : ''}
+ {uploading ? ` · ${Math.round(item.uploadProgress * 100)}%` : ''}
+
+
+ {failed && onRetry ? (
+
+ ) : null}
+ {onRemove ? (
+
+ ) : null}
+
+ )
+ })}
+ {addControl}
+
+ )
+}
diff --git a/src/features/assistant/session-files/FileTitleModal.tsx b/src/features/assistant/session-files/FileTitleModal.tsx
new file mode 100644
index 0000000..f143a73
--- /dev/null
+++ b/src/features/assistant/session-files/FileTitleModal.tsx
@@ -0,0 +1,71 @@
+import { useEffect, useState } from 'react'
+import { Modal } from '../../../components/Modal'
+import { Button } from '../../../components/Button'
+import { Field, Input } from '../../../components/ui/Field'
+import { defaultFileTitle } from './fileKind'
+
+export function FileTitleModal({
+ files,
+ onClose,
+ onConfirm,
+}: {
+ files: File[] | null
+ onClose: () => void
+ onConfirm: (files: File[], titles: string[]) => void
+}) {
+ const open = Boolean(files?.length)
+ const [titles, setTitles] = useState([])
+
+ useEffect(() => {
+ setTitles((files ?? []).map((file) => defaultFileTitle(file.name)))
+ }, [files])
+
+ if (!open || !files) return null
+
+ function confirm() {
+ const next = titles.map((title, index) => title.trim() || defaultFileTitle(files![index]!.name))
+ onConfirm(files!, next)
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/src/features/assistant/session-files/api.ts b/src/features/assistant/session-files/api.ts
new file mode 100644
index 0000000..f04379b
--- /dev/null
+++ b/src/features/assistant/session-files/api.ts
@@ -0,0 +1,151 @@
+import { apiFetch, kamidsoUrl } from '../../../services/api'
+import type {
+ CapabilitiesResult,
+ KamidsoFile,
+ SessionFilePreviewSnapshot,
+ SessionFileSnapshot,
+ SessionFilesApi,
+ UploadCapabilities,
+} from './types'
+import { DEFAULT_UPLOAD_CAPABILITIES, kamidsoFileToSnapshot } from './types'
+
+export class SessionFilesApiError extends Error {
+ readonly errCode: string | null
+ readonly statusCode?: number
+
+ constructor(message: string, errCode: string | null = null, statusCode?: number) {
+ super(message)
+ this.name = 'SessionFilesApiError'
+ this.errCode = errCode
+ this.statusCode = statusCode
+ }
+}
+
+export async function uploadFiles(params: {
+ sessionId: string
+ files: readonly File[]
+ title?: string
+ signal?: AbortSignal
+ onProgress?: (ratio: number) => void
+ timeoutMs?: number
+}): Promise {
+ const controller = new AbortController()
+ const timeout = window.setTimeout(() => controller.abort(), params.timeoutMs ?? 180000)
+ if (params.signal) {
+ params.signal.addEventListener('abort', () => controller.abort())
+ }
+
+ const results: SessionFileSnapshot[] = []
+ try {
+ for (const file of params.files) {
+ const form = new FormData()
+ form.append('file', file, file.name)
+ if (params.title?.trim()) form.append('title', params.title.trim())
+ const response = await fetch(kamidsoUrl('/files'), {
+ method: 'POST',
+ body: form,
+ credentials: 'include',
+ signal: controller.signal,
+ })
+ if (!response.ok) {
+ const data = await response.json().catch(() => null)
+ throw new SessionFilesApiError(
+ (data as { message?: string })?.message ?? 'Upload failed',
+ (data as { code?: string })?.code ?? null,
+ response.status,
+ )
+ }
+ const payload = (await response.json()) as { file: KamidsoFile }
+ results.push(kamidsoFileToSnapshot(payload.file))
+ params.onProgress?.(results.length / params.files.length)
+ }
+ return results
+ } finally {
+ window.clearTimeout(timeout)
+ }
+}
+
+export async function listFiles(_sessionId: string): Promise {
+ const files = await listWorkspaceFiles()
+ return files.map(kamidsoFileToSnapshot)
+}
+
+function asFileList(payload: unknown): KamidsoFile[] {
+ if (Array.isArray(payload)) return payload as KamidsoFile[]
+ if (payload && typeof payload === 'object') {
+ const obj = payload as { files?: unknown; data?: unknown }
+ if (Array.isArray(obj.files)) return obj.files as KamidsoFile[]
+ if (Array.isArray(obj.data)) return obj.data as KamidsoFile[]
+ if (obj.data && typeof obj.data === 'object' && Array.isArray((obj.data as { files?: unknown }).files)) {
+ return (obj.data as { files: KamidsoFile[] }).files
+ }
+ }
+ return []
+}
+
+export async function listWorkspaceFiles(): Promise {
+ const payload = await apiFetch('/files')
+ return asFileList(payload)
+}
+
+export async function uploadWorkspaceFile(file: File, title?: string): Promise {
+ const form = new FormData()
+ form.append('file', file, file.name)
+ if (title?.trim()) form.append('title', title.trim())
+ const payload = await apiFetch<{ file: KamidsoFile }>('/files', { method: 'POST', body: form })
+ return payload.file
+}
+
+export async function updateWorkspaceFile(fileId: string, title: string): Promise {
+ const payload = await apiFetch<{ file: KamidsoFile }>(`/files/${encodeURIComponent(fileId)}`, {
+ method: 'PATCH',
+ body: JSON.stringify({ title }),
+ })
+ return payload.file
+}
+
+export async function deleteFile(_sessionId: string, fileId: string): Promise {
+ await apiFetch(`/files/${encodeURIComponent(fileId)}`, { method: 'DELETE' })
+}
+
+export async function fetchCapabilities(): Promise {
+ try {
+ const payload = await apiFetch<{
+ max_file_size: number
+ supported_extensions: string[]
+ }>('/files/capabilities')
+ const capabilities: UploadCapabilities = {
+ ...DEFAULT_UPLOAD_CAPABILITIES,
+ max_file_bytes: payload.max_file_size,
+ supported_extensions: payload.supported_extensions,
+ }
+ return { capabilities, source: 'server' }
+ } catch (error) {
+ return { capabilities: DEFAULT_UPLOAD_CAPABILITIES, source: 'fallback', error }
+ }
+}
+
+export async function previewFile(
+ _sessionId: string,
+ fileId: string,
+): Promise {
+ const preview = await apiFetch>(`/files/${encodeURIComponent(fileId)}/preview`)
+ return {
+ file_id: fileId,
+ name: String(preview.name ?? fileId),
+ media_type: String(preview.mime_type ?? ''),
+ kind: String(preview.type ?? 'file'),
+ status: 'ready',
+ truncated: true,
+ preview,
+ error_code: null,
+ }
+}
+
+export const sessionFilesApi: SessionFilesApi = {
+ uploadFiles,
+ listFiles,
+ deleteFile,
+ fetchCapabilities,
+ previewFile,
+}
diff --git a/src/features/assistant/session-files/attachment-view-model.ts b/src/features/assistant/session-files/attachment-view-model.ts
new file mode 100644
index 0000000..a010705
--- /dev/null
+++ b/src/features/assistant/session-files/attachment-view-model.ts
@@ -0,0 +1,535 @@
+/**
+ * Pure view-model helpers for the session-files UI components.
+ *
+ * This module intentionally stays DOM/JSX-free: every helper that the rail,
+ * the history card group and the preview renderer depend on is derived here
+ * so it can be verified with `node --test` under Node's type stripping
+ * (AttachmentRail.test.ts). The .tsx components only wire these results into
+ * Ant Design JSX.
+ */
+
+import type { DraftFile, LegacyServerFile, PreviewPhase, SessionFileStatus, UploadPhase } from './types';
+
+// ---------------------------------------------------------------------------
+// Rail item view model
+// ---------------------------------------------------------------------------
+
+/** Flattened, render-ready view of one DraftFile row in the rail. */
+export interface RailItem {
+ clientId: string;
+ name: string;
+ size: number;
+ mediaType: string;
+ uploadStatus: UploadPhase;
+ /** 0..1 */
+ uploadProgress: number;
+ previewStatus: PreviewPhase;
+ /** Hard failure reason (upload or validation), if any. */
+ error: string | null;
+ /** Server file id once the upload completed. */
+ fileId: string | null;
+}
+
+export function toRailItems(drafts: readonly DraftFile[]): RailItem[] {
+ return drafts.map(draft => ({
+ clientId: draft.clientId,
+ name: draft.title || draft.snapshot?.name || draft.identity.name,
+ size: draft.identity.size,
+ mediaType: draft.file?.type || draft.snapshot?.media_type || '',
+ uploadStatus: draft.upload.status,
+ uploadProgress: draft.upload.progress,
+ previewStatus: draft.preview.status,
+ error: draft.validation.status === 'invalid' ? draft.validation.error : draft.upload.error,
+ fileId: draft.snapshot?.file_id ?? null,
+ }));
+}
+
+/**
+ * Read-only rail entry for a legacy server-preloaded file (example cards).
+ * It claims no upload state, retry, or preview: the card renders name, size
+ * and a server badge only, and the `legacy:` key is non-resolvable.
+ */
+export interface LegacyRailItem {
+ key: string;
+ name: string;
+ size: number;
+ mediaType: string;
+}
+
+export function toLegacyRailItem(file: LegacyServerFile): LegacyRailItem {
+ return {
+ key: `legacy:${file.file_path}`,
+ name: file.name,
+ size: file.size,
+ mediaType: file.media_type,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Status labels (upload and preview stay orthogonal)
+// ---------------------------------------------------------------------------
+
+export const UPLOAD_STATUS_LABELS: Record = Object.freeze({
+ queued: 'Queued',
+ uploading: 'Uploading',
+ done: 'Ready',
+ failed: 'Upload failed',
+ cancelled: 'Cancelled',
+ blocked: 'Blocked',
+});
+
+export const PREVIEW_STATUS_LABELS: Record = Object.freeze({
+ idle: '',
+ loading: 'Loading preview',
+ ready: 'Preview ready',
+ preview_failed: 'Preview unavailable',
+});
+
+/** Hard failures (upload/validation) render destructive and demand attention. */
+export function isHardFailure(item: Pick): boolean {
+ return item.uploadStatus === 'failed' || item.uploadStatus === 'blocked';
+}
+
+/** Only real upload failures can be retried; validation-blocked items cannot. */
+export function canRetry(item: Pick): boolean {
+ return item.uploadStatus === 'failed';
+}
+
+// ---------------------------------------------------------------------------
+// Aggregate progress (deterministic aria-valuenow source)
+// ---------------------------------------------------------------------------
+
+function clamp01(value: number): number {
+ return Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0));
+}
+
+/**
+ * Mean upload progress across in-flight items (queued → 0, uploading → its
+ * ratio, done → 1). Failed/cancelled/blocked items are excluded; 0 when the
+ * queue is idle so the bar can hide without flickering.
+ */
+export function aggregateUploadProgress(items: readonly Pick[]): number {
+ let total = 0;
+ let count = 0;
+ for (const item of items) {
+ if (item.uploadStatus === 'done') {
+ total += 1;
+ count += 1;
+ } else if (item.uploadStatus === 'uploading' || item.uploadStatus === 'queued') {
+ total += clamp01(item.uploadProgress);
+ count += 1;
+ }
+ }
+ return count === 0 ? 0 : total / count;
+}
+
+/** Integer percent for `aria-valuenow`; deterministic for screen readers. */
+export function progressPercent(ratio: number): number {
+ return Math.round(clamp01(ratio) * 100);
+}
+
+// ---------------------------------------------------------------------------
+// Accessibility names
+// ---------------------------------------------------------------------------
+
+export function removeAriaLabel(name: string): string {
+ return `Remove ${name}`;
+}
+
+export function retryAriaLabel(name: string): string {
+ return `Retry upload for ${name}`;
+}
+
+// ---------------------------------------------------------------------------
+// First rows + "+N" collapse
+// ---------------------------------------------------------------------------
+
+/** Rows (one per item) shown before the remainder collapses into `+N`. */
+export const RAIL_MAX_VISIBLE = 6;
+
+export function collapseRail(
+ items: readonly T[],
+ maxVisible = RAIL_MAX_VISIBLE,
+): { visible: readonly T[]; hiddenCount: number } {
+ if (items.length <= maxVisible) {
+ return { visible: items, hiddenCount: 0 };
+ }
+ return { visible: items.slice(0, maxVisible), hiddenCount: items.length - maxVisible };
+}
+
+/**
+ * A single removable draft already communicates its own metadata and remove
+ * action. Multi-file and legacy sets keep the summary for count/total and the
+ * legacy-only clear affordance.
+ */
+export function shouldShowComfortableSummary(input: {
+ totalCount: number;
+ legacyCount: number;
+ hasPerItemRemove: boolean;
+}): boolean {
+ if (input.totalCount <= 0) return false;
+ return input.totalCount > 1 || input.legacyCount > 0 || !input.hasPerItemRemove;
+}
+
+// ---------------------------------------------------------------------------
+// Compact composer rail
+// ---------------------------------------------------------------------------
+
+/**
+ * Compact mode reserves room for the overflow manager and add-file control.
+ * These breakpoints intentionally use the rail's own measured width rather
+ * than the viewport: the inference panel may be narrow on a desktop screen.
+ */
+export function resolveCompactRailLimit(containerWidth: number): 0 | 1 | 2 | 3 {
+ const width = Number.isFinite(containerWidth) ? Math.max(0, containerWidth) : 0;
+ if (width < 280) return 0;
+ if (width < 320) return 1;
+ if (width < 560) return 2;
+ return 3;
+}
+
+export interface CompactRailLayout {
+ /** Most recently added entries kept visible beside the add control. */
+ visible: readonly T[];
+ /** Older entries represented by the +N manager. */
+ hidden: readonly T[];
+ hiddenCount: number;
+}
+
+/**
+ * Keep the newest entries visible so adding a file always gives immediate
+ * visual confirmation. Hidden entries retain their original ordering.
+ */
+export function collapseCompactRail(items: readonly T[], containerWidth: number): CompactRailLayout {
+ const limit = resolveCompactRailLimit(containerWidth);
+ const hiddenCount = Math.max(0, items.length - limit);
+ return {
+ visible: items.slice(hiddenCount),
+ hidden: items.slice(0, hiddenCount),
+ hiddenCount,
+ };
+}
+
+export interface CompactRailStatusSummary {
+ errorCount: number;
+ processingCount: number;
+ warningCount: number;
+}
+
+/** Status signal displayed on +N so folded failures never disappear. */
+export function summarizeCompactRailStatus(
+ items: readonly Pick[],
+): CompactRailStatusSummary {
+ return items.reduce(
+ (summary, item) => {
+ if (isHardFailure(item)) summary.errorCount += 1;
+ else if (
+ item.uploadStatus === 'queued' ||
+ item.uploadStatus === 'uploading' ||
+ (item.uploadStatus === 'done' && item.previewStatus === 'loading')
+ ) {
+ summary.processingCount += 1;
+ } else if (item.previewStatus === 'preview_failed') {
+ summary.warningCount += 1;
+ }
+ return summary;
+ },
+ { errorCount: 0, processingCount: 0, warningCount: 0 },
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Preview eligibility
+// ---------------------------------------------------------------------------
+
+/** Drafts: only a completed upload with a settled preview may be previewed. */
+export function canPreviewItem(item: Pick): boolean {
+ return item.uploadStatus === 'done' && (item.previewStatus === 'ready' || item.previewStatus === 'preview_failed');
+}
+
+/** Snapshots (history cards): same rule on the server lifecycle status. */
+export function canPreviewSnapshot(status: SessionFileStatus): boolean {
+ return status === 'ready' || status === 'preview_failed';
+}
+
+// ---------------------------------------------------------------------------
+// Overlay breakpoint selection
+// ---------------------------------------------------------------------------
+
+export const PREVIEW_DESKTOP_MIN_WIDTH = 1024;
+export const PREVIEW_FULLSCREEN_MAX_WIDTH = 640;
+export const PREVIEW_DRAWER_MAX_WIDTH = 720;
+export const PREVIEW_DRAWER_VIEWPORT_RATIO = 0.9;
+
+export type PreviewOverlayLayout = { mode: 'right-panel' } | { mode: 'drawer'; width: number } | { mode: 'fullscreen' };
+
+/**
+ * Desktop (≥1024px) reuses the chat right panel; tablet (640–1023px) opens an
+ * overlay Drawer of `min(720px, 90vw)`; phones (<640px) go fullscreen so the
+ * chat column never gets squeezed.
+ */
+export function resolvePreviewOverlay(viewportWidth: number): PreviewOverlayLayout {
+ if (viewportWidth >= PREVIEW_DESKTOP_MIN_WIDTH) return { mode: 'right-panel' };
+ if (viewportWidth >= PREVIEW_FULLSCREEN_MAX_WIDTH) {
+ return {
+ mode: 'drawer',
+ width: Math.min(PREVIEW_DRAWER_MAX_WIDTH, Math.round(viewportWidth * PREVIEW_DRAWER_VIEWPORT_RATIO)),
+ };
+ }
+ return { mode: 'fullscreen' };
+}
+
+// ---------------------------------------------------------------------------
+// Reduced motion
+// ---------------------------------------------------------------------------
+
+/** Applied to the rail root; CSS under this class disables animations. */
+export const REDUCED_MOTION_CLASS = 'session-files-reduced-motion';
+
+export function motionClassName(prefersReducedMotion: boolean): string {
+ return prefersReducedMotion ? REDUCED_MOTION_CLASS : '';
+}
+
+// ---------------------------------------------------------------------------
+// Formatting + icon selection
+// ---------------------------------------------------------------------------
+
+export function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes)) return '0 B';
+ const KB = 1024;
+ const MB = KB * 1024;
+ const GB = MB * 1024;
+ if (bytes < KB) return `${bytes} B`;
+ if (bytes < MB) return `${(bytes / KB).toFixed(2)} KB`;
+ if (bytes < GB) return `${(bytes / MB).toFixed(2)} MB`;
+ return `${(bytes / GB).toFixed(2)} GB`;
+}
+
+export type FileIconKey = 'table' | 'image' | 'slide' | 'text' | 'file';
+
+const TABLE_EXTS = new Set(['csv', 'tsv', 'xls', 'xlsx', 'parquet', 'json', 'jsonl']);
+const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp']);
+const SLIDE_EXTS = new Set(['ppt', 'pptx']);
+const TEXT_EXTS = new Set(['txt', 'md', 'markdown', 'log', 'doc', 'docx', 'rtf', 'pdf']);
+
+export function fileIconKey(input: { name?: string; mediaType?: string; kind?: string }): FileIconKey {
+ const mediaType = (input.mediaType ?? '').toLowerCase();
+ if (
+ input.kind === 'table' ||
+ mediaType.includes('spreadsheet') ||
+ mediaType.includes('excel') ||
+ mediaType.includes('csv')
+ ) {
+ return 'table';
+ }
+ if (mediaType.startsWith('image/')) return 'image';
+ if (mediaType.includes('presentation')) return 'slide';
+ const ext = (input.name ?? '').toLowerCase().split('.').pop() ?? '';
+ if (TABLE_EXTS.has(ext)) return 'table';
+ if (IMAGE_EXTS.has(ext)) return 'image';
+ if (SLIDE_EXTS.has(ext)) return 'slide';
+ if (
+ TEXT_EXTS.has(ext) ||
+ mediaType.startsWith('text/') ||
+ mediaType.includes('pdf') ||
+ mediaType.includes('word') ||
+ mediaType.includes('markdown')
+ ) {
+ return 'text';
+ }
+ return 'file';
+}
+
+// ---------------------------------------------------------------------------
+// Preview payload normalization (AttachmentPreview)
+// ---------------------------------------------------------------------------
+
+export type PreviewMode = 'table' | 'text' | 'document' | 'empty';
+
+/**
+ * User-facing scope shown beside the previewed file metadata. The view-model
+ * stays i18n-agnostic: it returns translation keys (+ interpolation params)
+ * and the React layer renders them through `t()` — same pattern as
+ * `typeLabelKey` in AttachmentMessageCards.
+ */
+export interface PreviewScopeSummary {
+ labelKey:
+ | 'session_files_scope_limited'
+ | 'session_files_scope_table_empty'
+ | 'session_files_scope_table_rows'
+ | 'session_files_scope_document'
+ | 'session_files_scope_content';
+ labelParams?: { count: number };
+ partial: boolean;
+ hintKey: 'session_files_scope_truncated_hint' | null;
+}
+
+/**
+ * Describe what is visibly rendered without pretending `truncated` means a
+ * row-only limit. The backend may truncate rows, columns, sheets, pages or
+ * bytes, so table row counts always come from the normalized visible rows.
+ */
+export function buildPreviewScopeSummary(input: {
+ mode: PreviewMode;
+ truncated: boolean;
+ visibleRows?: number;
+}): PreviewScopeSummary | null {
+ const hintKey: PreviewScopeSummary['hintKey'] = input.truncated ? 'session_files_scope_truncated_hint' : null;
+
+ if (input.mode === 'empty') {
+ return input.truncated
+ ? {
+ labelKey: 'session_files_scope_limited',
+ partial: true,
+ hintKey,
+ }
+ : null;
+ }
+
+ if (input.mode === 'table') {
+ const rows = Number.isFinite(input.visibleRows) ? Math.max(0, Math.floor(input.visibleRows ?? 0)) : 0;
+ return rows === 0
+ ? {
+ labelKey: 'session_files_scope_table_empty',
+ partial: input.truncated,
+ hintKey,
+ }
+ : {
+ labelKey: 'session_files_scope_table_rows',
+ labelParams: { count: rows },
+ partial: input.truncated,
+ hintKey,
+ };
+ }
+
+ return {
+ labelKey: input.mode === 'document' ? 'session_files_scope_document' : 'session_files_scope_content',
+ partial: input.truncated,
+ hintKey,
+ };
+}
+
+export interface TablePreviewData {
+ columns: string[];
+ rows: unknown[][];
+}
+
+export interface DocumentPreviewData {
+ metadata: Record;
+ text: string | null;
+}
+
+function isPlainRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/** Columns + rows arrays (or record rows) aligned to the column order. */
+/** Header cells may be numbers/null (xlsx); table columns must be strings. */
+const headerCellText = (cell: unknown): string => {
+ if (cell === null || cell === undefined) return '';
+ return String(cell);
+};
+
+export function normalizeTablePreview(preview: Record | null | undefined): TablePreviewData | null {
+ if (!isPlainRecord(preview)) return null;
+
+ // Shape A — explicit columns (JSON/JSONL inspector output). Rows may be
+ // arrays or records keyed by column name.
+ const columns = preview.columns;
+ if (Array.isArray(columns) && !columns.some(column => typeof column !== 'string')) {
+ const typedColumns = columns as string[];
+ const rows = preview.rows;
+ if (!Array.isArray(rows)) return null;
+ const normalizedRows: unknown[][] = rows.map(row => {
+ if (Array.isArray(row)) {
+ return typedColumns.map((_, index) => row[index]);
+ }
+ if (isPlainRecord(row)) {
+ return typedColumns.map(column => row[column]);
+ }
+ return typedColumns.map(() => undefined);
+ });
+ return { columns: typedColumns, rows: normalizedRows };
+ }
+
+ // Shape B — raw delimited rows (CSV/TSV inspector output: no `columns`,
+ // the first row is the header, mirroring spreadsheet conventions).
+ const rawRows = preview.rows;
+ if (Array.isArray(rawRows) && rawRows.length > 0 && Array.isArray(rawRows[0])) {
+ const header = (rawRows[0] as unknown[]).map(headerCellText);
+ const rows = rawRows
+ .slice(1)
+ .map(row => (Array.isArray(row) ? header.map((_, index) => row[index]) : header.map(() => undefined)));
+ return { columns: header, rows };
+ }
+
+ // Shape C — workbook sheets (XLSX/XLS inspector output). The first sheet
+ // represents the table; its first row is the header.
+ const sheets = preview.sheets;
+ if (Array.isArray(sheets) && sheets.length > 0) {
+ const first = sheets[0];
+ if (isPlainRecord(first) && Array.isArray(first.rows) && first.rows.length > 0 && Array.isArray(first.rows[0])) {
+ const header = (first.rows[0] as unknown[]).map(headerCellText);
+ const rows = first.rows
+ .slice(1)
+ .map(row => (Array.isArray(row) ? header.map((_, index) => row[index]) : header.map(() => undefined)));
+ return { columns: header, rows };
+ }
+ }
+
+ return null;
+}
+
+/** Plain text body of a text/markdown preview (`text` or `content`). */
+export function normalizeTextPreview(preview: Record | null | undefined): string | null {
+ if (!isPlainRecord(preview)) return null;
+ if (typeof preview.text === 'string') return preview.text;
+ if (typeof preview.content === 'string') return preview.content;
+ return null;
+}
+
+/** Document metadata plus optional extracted body text. */
+export function normalizeDocumentPreview(
+ preview: Record | null | undefined,
+): DocumentPreviewData | null {
+ if (!isPlainRecord(preview)) return null;
+ const metadata =
+ isPlainRecord(preview.metadata) && Object.keys(preview.metadata).length > 0 ? preview.metadata : null;
+ const text =
+ typeof preview.text === 'string' ? preview.text : typeof preview.content === 'string' ? preview.content : null;
+ if (!metadata && text === null) return null;
+ return { metadata: metadata ?? {}, text };
+}
+
+/**
+ * Route a preview payload to a render mode. Empty/missing payloads never
+ * pretend to be renderable — the component shows a graceful empty state.
+ */
+export function resolvePreviewMode(input: {
+ kind?: string;
+ mediaType?: string;
+ preview?: Record | null;
+}): PreviewMode {
+ const preview = input.preview;
+ if (!isPlainRecord(preview) || Object.keys(preview).length === 0) return 'empty';
+
+ const kind = input.kind ?? '';
+ const media = (input.mediaType ?? '').toLowerCase();
+
+ const isTableLike =
+ kind === 'table' || media.includes('csv') || media.includes('spreadsheet') || media.includes('excel');
+ if (isTableLike) return normalizeTablePreview(preview) ? 'table' : 'empty';
+
+ const isDocumentLike =
+ kind === 'document' || media.includes('pdf') || media.includes('word') || media.includes('presentation');
+ if (isDocumentLike) return normalizeDocumentPreview(preview) ? 'document' : 'empty';
+
+ const isTextLike = kind === 'text' || kind === 'markdown' || media.startsWith('text/');
+ if (isTextLike) return normalizeTextPreview(preview) !== null ? 'text' : 'empty';
+
+ // Unknown kind: opportunistically match the payload shape.
+ if (normalizeTablePreview(preview)) return 'table';
+ if (normalizeTextPreview(preview) !== null) return 'text';
+ if (normalizeDocumentPreview(preview)) return 'document';
+ return 'empty';
+}
diff --git a/src/features/assistant/session-files/fileKind.test.ts b/src/features/assistant/session-files/fileKind.test.ts
new file mode 100644
index 0000000..86d1625
--- /dev/null
+++ b/src/features/assistant/session-files/fileKind.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+import {
+ defaultFileTitle,
+ fileKindLabel,
+ fileKindLine,
+ isDataFileKind,
+ isDataFilename,
+ isKnowledgeFileKind,
+ isKnowledgeFilename,
+} from './fileKind'
+
+describe('fileKind', () => {
+ it('builds a display title from the filename stem', () => {
+ expect(defaultFileTitle('Book1 (1).xlsx')).toBe('Book1 (1)')
+ expect(defaultFileTitle('C:\\\\reports\\\\sales.csv')).toBe('sales')
+ })
+
+ it('writes tabular kinds as data files and documents as knowledge', () => {
+ expect(fileKindLine('excel')).toBe('فایل داده · Excel')
+ expect(fileKindLine('csv')).toBe('فایل داده · CSV')
+ expect(fileKindLine('file')).toBe('فایل')
+ expect(fileKindLine('pdf')).toBe('سند · PDF')
+ expect(fileKindLabel('pdf')).toBe('PDF')
+ })
+
+ it('splits data files from knowledge documents', () => {
+ expect(isDataFileKind('excel')).toBe(true)
+ expect(isDataFileKind('spreadsheet')).toBe(true)
+ expect(isDataFileKind('pdf')).toBe(false)
+ expect(isKnowledgeFileKind('pdf')).toBe(true)
+ expect(isKnowledgeFileKind('csv')).toBe(false)
+ expect(isDataFilename('sales.xlsx')).toBe(true)
+ expect(isDataFilename('policy.docx')).toBe(false)
+ expect(isKnowledgeFilename('policy.pdf')).toBe(true)
+ })
+})
diff --git a/src/features/assistant/session-files/fileKind.ts b/src/features/assistant/session-files/fileKind.ts
new file mode 100644
index 0000000..52b59b2
--- /dev/null
+++ b/src/features/assistant/session-files/fileKind.ts
@@ -0,0 +1,78 @@
+const KIND_LABELS: Record = {
+ excel: 'Excel',
+ csv: 'CSV',
+ json: 'JSON',
+ parquet: 'Parquet',
+ table: 'Excel',
+ spreadsheet: 'Excel',
+ pdf: 'PDF',
+ document: 'سند',
+ presentation: 'ارائه',
+ text: 'متن',
+ file: 'فایل',
+}
+
+const EXT_TO_KIND: Record = {
+ '.xlsx': 'excel',
+ '.xls': 'excel',
+ '.csv': 'csv',
+ '.tsv': 'csv',
+ '.json': 'json',
+ '.jsonl': 'json',
+ '.parquet': 'parquet',
+ '.pdf': 'pdf',
+ '.doc': 'document',
+ '.docx': 'document',
+ '.ppt': 'presentation',
+ '.pptx': 'presentation',
+ '.md': 'text',
+ '.markdown': 'text',
+ '.txt': 'text',
+}
+
+export const DATA_FILE_KINDS = new Set(['excel', 'csv', 'json', 'parquet', 'table', 'spreadsheet'])
+export const KNOWLEDGE_FILE_KINDS = new Set(['pdf', 'document', 'presentation', 'text'])
+
+export const DATA_FILE_ACCEPT = '.csv,.tsv,.xls,.xlsx,.json,.jsonl,.parquet'
+export const KNOWLEDGE_FILE_ACCEPT = '.pdf,.doc,.docx,.ppt,.pptx,.txt,.md,.markdown'
+
+export function kindFromFilename(name: string): string {
+ const ext = name.toLowerCase().match(/\.[^.]+$/)?.[0] || ''
+ return EXT_TO_KIND[ext] || 'file'
+}
+
+export function isDataFileKind(kind?: string | null): boolean {
+ return DATA_FILE_KINDS.has((kind || '').toLowerCase())
+}
+
+export function isKnowledgeFileKind(kind?: string | null): boolean {
+ return KNOWLEDGE_FILE_KINDS.has((kind || '').toLowerCase())
+}
+
+export function isDataFilename(name: string): boolean {
+ return isDataFileKind(kindFromFilename(name))
+}
+
+export function isKnowledgeFilename(name: string): boolean {
+ return isKnowledgeFileKind(kindFromFilename(name))
+}
+
+export function fileKindLabel(kind?: string | null): string {
+ const key = (kind || 'file').toLowerCase()
+ return KIND_LABELS[key] || kind || 'فایل'
+}
+
+/** Product line on Databases / data picker: "فایل داده · Excel". Documents: "سند · PDF". */
+export function fileKindLine(kind?: string | null): string {
+ const key = (kind || 'file').toLowerCase()
+ if (!key || key === 'file') return 'فایل'
+ if (isDataFileKind(key)) return `فایل داده · ${fileKindLabel(key)}`
+ if (isKnowledgeFileKind(key)) return `سند · ${fileKindLabel(key)}`
+ return `فایل · ${fileKindLabel(key)}`
+}
+
+export function defaultFileTitle(filename: string): string {
+ const base = filename.replace(/^.*[/\\]/, '').trim()
+ const stem = base.replace(/\.[^.]+$/, '').trim()
+ return (stem || base || 'file').slice(0, 120)
+}
diff --git a/src/features/assistant/session-files/home-adapter.ts b/src/features/assistant/session-files/home-adapter.ts
new file mode 100644
index 0000000..0438bd3
--- /dev/null
+++ b/src/features/assistant/session-files/home-adapter.ts
@@ -0,0 +1,221 @@
+/**
+ * Home-page adapter for the session-files module.
+ *
+ * Pure, network-free bridge between the chat wire payloads and the module's
+ * UI contract:
+ *
+ * - history payload v2 `input_files` → frozen `SessionFileSnapshot[]` (one
+ * entry per file attached to a turn). Owner-side history carries the real
+ * `file_id`; public share pages and frozen scheduled-task snapshots carry
+ * the non-resolvable `display_key` instead, and this module never invents a
+ * usable server id;
+ * - legacy v1 history carries no file snapshot: parsing a v1 payload returns
+ * an empty list, so those messages render without attachment cards;
+ * - legacy example files (preloaded on the server via Kamidso admin examples API)
+ * are staged as frozen `LegacyServerFile`s on the send snapshot and resent
+ * verbatim through the legacy `file_path` protocol. `snapshotFromLegacyFile`
+ * renders them display-only and `extInfoForSend` refuses to mix them with
+ * session `file_ids`;
+ * - the send-path ext_info builder is regression-safe: with zero files it
+ * reproduces the legacy file_path / plain-text payload byte-for-byte (no
+ * `file_ids`/`session_id` keys appear).
+ */
+
+import type { LegacyServerFile, SessionFileSnapshot, SessionFileStatus, SessionFilesSendSnapshot } from './types';
+
+/** History payload version carrying the public `input_files` snapshot. */
+export const REACT_HISTORY_PAYLOAD_VERSION = 2;
+
+const SESSION_FILE_STATUSES: readonly SessionFileStatus[] = [
+ 'uploading',
+ 'inspecting',
+ 'ready',
+ 'preview_failed',
+ 'failed',
+ 'deleted',
+];
+
+function toSnapshot(entry: unknown, index: number): SessionFileSnapshot | null {
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return null;
+ const wire = entry as Record;
+
+ const name = wire.name;
+ if (typeof name !== 'string' || !name) return null;
+
+ // Owner history carries the real file_id; share-scrubbed and frozen-task
+ // entries carry the non-resolvable display_key. When both are missing a
+ // synthesised ordinal key keeps rendering deterministic without ever
+ // resembling a usable server id.
+ const rawId = wire.file_id ?? wire.display_key;
+ const fileId = typeof rawId === 'string' && rawId ? rawId : `anonymous-attachment-${index + 1}`;
+
+ const size = typeof wire.size === 'number' && Number.isFinite(wire.size) && wire.size >= 0 ? wire.size : 0;
+ const status =
+ typeof wire.status === 'string' && (SESSION_FILE_STATUSES as readonly string[]).includes(wire.status)
+ ? (wire.status as SessionFileStatus)
+ : 'ready';
+ return Object.freeze({
+ file_id: fileId,
+ name,
+ size,
+ media_type: typeof wire.media_type === 'string' ? wire.media_type : '',
+ kind: typeof wire.kind === 'string' ? wire.kind : '',
+ status,
+ ordinal: Number.isInteger(wire.ordinal) ? (wire.ordinal as number) : index,
+ error_code: typeof wire.error_code === 'string' ? wire.error_code : null,
+ });
+}
+
+/**
+ * Parse the public `input_files` snapshot of a history payload v2 (or an
+ * `ext_info.input_files` task freeze list) into frozen snapshots. Anything
+ * invalid degrades safely: non-arrays yield an empty list and malformed
+ * entries are skipped, so a corrupt payload can never break the page.
+ */
+export function snapshotsFromInputFiles(inputFiles: unknown): readonly SessionFileSnapshot[] {
+ if (!Array.isArray(inputFiles)) return Object.freeze([]);
+ const snapshots: SessionFileSnapshot[] = [];
+ inputFiles.forEach((entry, index) => {
+ const snapshot = toSnapshot(entry, index);
+ if (snapshot) snapshots.push(snapshot);
+ });
+ return Object.freeze(snapshots);
+}
+
+/** Files attached to one human turn, as restored from a view payload. */
+export interface ViewContextFiles {
+ version: 1 | 2;
+ inputFiles: readonly SessionFileSnapshot[];
+}
+
+/**
+ * Read the files attached to a turn from a view (react-agent) history
+ * payload. v1 predates file snapshots, so it returns an empty list and
+ * those messages render without attachment cards. Non-JSON payloads and
+ * non-react-agent contexts return null.
+ */
+export function parseViewContextFiles(context: unknown): ViewContextFiles | null {
+ if (typeof context !== 'string' || !context) return null;
+ let payload: unknown;
+ try {
+ payload = JSON.parse(context);
+ } catch {
+ return null;
+ }
+ if (typeof payload !== 'object' || payload === null) return null;
+ const wire = payload as Record;
+ if (wire.type !== 'react-agent') return null;
+ if (wire.version === 1) return { version: 1, inputFiles: Object.freeze([]) };
+ if (wire.version === REACT_HISTORY_PAYLOAD_VERSION) {
+ return { version: 2, inputFiles: snapshotsFromInputFiles(wire.input_files) };
+ }
+ return null;
+}
+
+/**
+ * Minimal public back-compat form of the legacy one-item attachment that used
+ * to be declared as `FileAttachment` inside pages/index.tsx. Provided here so
+ * all legacy-file handling flows through this adapter seam.
+ */
+export interface LegacyAttachedFile {
+ name: string;
+ size: number;
+ type: string;
+}
+
+/**
+ * Render a legacy file (FileAttachment / staged example file) as a
+ * display-only snapshot. The `legacy:` identity is non-resolvable by design
+ * and the server-side `file_path` is never surfaced, so nothing built from
+ * this snapshot can reach the file endpoints.
+ */
+export function snapshotFromLegacyFile(file: LegacyAttachedFile): SessionFileSnapshot {
+ return Object.freeze({
+ file_id: `legacy:${file.name}`,
+ name: file.name,
+ size: file.size,
+ media_type: file.type,
+ kind: '',
+ status: 'ready',
+ ordinal: 0,
+ error_code: null,
+ });
+}
+
+/**
+ * Return a copy of a send snapshot with a legacy example file staged. The
+ * caller guarantees the mutual exclusion (no `fileIds` while a legacy file is
+ * present); `extInfoForSend` still re-checks before building the payload.
+ */
+export function snapshotWithLegacyFile(
+ snapshot: SessionFilesSendSnapshot,
+ file: LegacyServerFile,
+): SessionFilesSendSnapshot {
+ return Object.freeze({ ...snapshot, legacyFile: Object.freeze({ ...file }) });
+}
+
+/** Display snapshots attached to the sent human message, in send order. */
+export function snapshotsForSend(snapshot: SessionFilesSendSnapshot): readonly SessionFileSnapshot[] {
+ const attachments = snapshot.fileIds.map(fileId => snapshot.snapshotById[fileId]);
+ const legacy = snapshot.legacyFile;
+ if (legacy) {
+ // The mixing guard in extInfoForSend keeps `attachments` empty here, so
+ // the legacy card is the only attachment appended.
+ attachments.push(snapshotFromLegacyFile({ name: legacy.name, size: legacy.size, type: legacy.media_type }));
+ }
+ return Object.freeze(attachments);
+}
+
+/**
+ * Build the send-time ext_info. Zero files reproduce the legacy payload
+ * exactly (no `file_ids`/`session_id` keys), preserving the byte-for-byte
+ * file_path / plain-text regression contract. With files it adds:
+ *
+ * - `file_ids`: ordered ids from the frozen send snapshot (the backend
+ * conflicts check forbids combining this with `file_path`, which the caller
+ * therefore only emits on the legacy path);
+ * - `session_id`: the owning conversation id, required so a later
+ * "save as scheduled task" can freeze these session-scoped files;
+ * - `input_files`: display-safe snapshots in the exact share-scrub shape
+ * (`display_key` + public metadata, never a resolvable id or path), so the
+ * scheduled-task surfaces can render what will be frozen.
+ *
+ * A staged legacy example file instead adds only `file_path`; mixing it with
+ * `file_ids` throws SESSION_FILES_MIXED_PROTOCOL before any payload is built.
+ */
+export function extInfoForSend(
+ base: Record | null | undefined,
+ snapshot: SessionFilesSendSnapshot | null | undefined,
+): Record {
+ const safeBase: Record = base ?? {};
+ if (!snapshot) {
+ return { ...safeBase };
+ }
+ const legacy = snapshot.legacyFile ?? null;
+ if (legacy) {
+ if (snapshot.fileIds.length > 0) {
+ throw new Error('SESSION_FILES_MIXED_PROTOCOL: legacy file_path cannot be combined with session file_ids');
+ }
+ return { ...safeBase, file_path: legacy.file_path };
+ }
+ if (snapshot.fileIds.length === 0) {
+ return { ...safeBase };
+ }
+ return {
+ ...safeBase,
+ file_ids: [...snapshot.fileIds],
+ session_id: snapshot.sessionId,
+ input_files: snapshot.fileIds.map((fileId, index) => {
+ const file = snapshot.snapshotById[fileId];
+ return {
+ display_key: `file-${index + 1}`,
+ name: file.name,
+ size: file.size,
+ media_type: file.media_type,
+ kind: file.kind,
+ status: file.status,
+ ordinal: file.ordinal,
+ };
+ }),
+ };
+}
diff --git a/src/features/assistant/session-files/index.ts b/src/features/assistant/session-files/index.ts
new file mode 100644
index 0000000..00e2ccb
--- /dev/null
+++ b/src/features/assistant/session-files/index.ts
@@ -0,0 +1,24 @@
+export { default as AttachmentMessageCards } from './AttachmentMessageCards'
+export { default as AttachmentMessageGroup } from './AttachmentMessageGroup'
+export { default as AttachmentPreview, AttachmentPreviewCloseButton, AttachmentPreviewPanelTitle } from './AttachmentPreview'
+export { default as AttachmentRail, AttachmentRailAddButton } from './AttachmentRail'
+export { useSessionFiles } from './use-session-files'
+export type { UseSessionFiles, UseSessionFilesOptions } from './use-session-files'
+export { SessionFilesApiError, previewFile, sessionFilesApi, listWorkspaceFiles, uploadWorkspaceFile, updateWorkspaceFile, deleteFile } from './api'
+export { kamidsoFileToSnapshot } from './types'
+export { extInfoForSend, snapshotsForSend, snapshotsFromInputFiles } from './home-adapter'
+export { defaultFileTitle, fileKindLabel, fileKindLine, isDataFileKind, isKnowledgeFileKind } from './fileKind'
+export {
+ DATA_FILE_ACCEPT,
+ KNOWLEDGE_FILE_ACCEPT,
+} from './fileKind'
+export { DEFAULT_UPLOAD_CAPABILITIES } from './types'
+export type {
+ DraftFile,
+ KamidsoFile,
+ LegacyServerFile,
+ SessionFilePreviewSnapshot,
+ SessionFileSnapshot,
+ SessionFilesSendSnapshot,
+ UploadCapabilities,
+} from './types'
diff --git a/src/features/assistant/session-files/recovery.ts b/src/features/assistant/session-files/recovery.ts
new file mode 100644
index 0000000..6ca043a
--- /dev/null
+++ b/src/features/assistant/session-files/recovery.ts
@@ -0,0 +1,112 @@
+/**
+ * Recovery view-model for the public share page and scheduled-task surfaces.
+ *
+ * Owner-side history resolves real `sf_…` server ids so previews can call the
+ * auth-protected endpoints. The surfaces served by this module must never do
+ * that:
+ *
+ * - the public share page renders names/statuses only; a snapshot identity is
+ * always the non-resolvable `display_key` (or a synthetic placeholder), so
+ * no component can construct a preview/download URL from a share payload —
+ * even if a stored payload was polluted with a real private ``file_id``;
+ * - scheduled-task surfaces render the frozen `ext_info.input_files` list the
+ * same way (metadata only), while the replay-time task-scoped `file_ids`
+ * stay server-side;
+ * - legacy tasks keep `ext_info.file_path` display-only: only the basename is
+ * shown, and it is never turned into a URL.
+ *
+ * Everything here is DOM/JSX-free so it can be verified with `node --test`
+ * (recovery.test.ts); the .tsx pages only wire these results into Ant Design.
+ */
+
+import { REACT_HISTORY_PAYLOAD_VERSION, snapshotsFromInputFiles } from './home-adapter';
+import type { SessionFileSnapshot } from './types';
+
+// ---------------------------------------------------------------------------
+// Display-only snapshots (share page + scheduled task surfaces)
+// ---------------------------------------------------------------------------
+
+/**
+ * Drop a stored `file_id` before parsing so the base adapter can never adopt
+ * a resolvable identity. Non-entry values pass through untouched (the base
+ * parser skips them deterministically).
+ */
+function stripPrivateIdentity(entry: unknown): unknown {
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return entry;
+ const wire = { ...(entry as Record) };
+ delete wire.file_id;
+ return wire;
+}
+
+/**
+ * Parse wire `input_files` entries into frozen snapshots whose identity is
+ * never a server-resolvable id. Display-keyed entries keep their
+ * `display_key`; entries that wrongly carry a private `file_id` are
+ * identity-stripped and fall back to a synthetic placeholder; names, sizes,
+ * statuses and ordinals render unchanged either way.
+ */
+export function displayOnlySnapshots(inputFiles: unknown): readonly SessionFileSnapshot[] {
+ if (!Array.isArray(inputFiles)) return Object.freeze([]);
+ return snapshotsFromInputFiles(inputFiles.map(stripPrivateIdentity));
+}
+
+// ---------------------------------------------------------------------------
+// Share replay payload gate
+// ---------------------------------------------------------------------------
+
+export interface ShareViewPayload {
+ version: 1 | 2;
+ /** The parsed react-agent payload; callers narrow the fields they render. */
+ payload: Record;
+}
+
+/**
+ * Accept react history versions 1 and 2 for public replay. Anything else
+ * (malformed JSON, non-react-agent payloads, unknown future versions)
+ * yields null so the replay page can skip the message without crashing.
+ */
+export function parseShareViewPayload(context: unknown): ShareViewPayload | null {
+ if (typeof context !== 'string' || !context) return null;
+ let payload: unknown;
+ try {
+ payload = JSON.parse(context);
+ } catch {
+ return null;
+ }
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return null;
+ const wire = payload as Record;
+ if (wire.type !== 'react-agent') return null;
+ if (wire.version === 1) return { version: 1, payload: wire };
+ if (wire.version === REACT_HISTORY_PAYLOAD_VERSION) {
+ return { version: 2, payload: wire };
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Scheduled task surfaces
+// ---------------------------------------------------------------------------
+
+/**
+ * Render-model of a task payload's frozen file list
+ * (`payload.ext_info.input_files`). The replay-time task-scoped
+ * `ext_info.file_ids` are server-side keys and are never surfaced here;
+ * legacy tasks (which carry only `file_path`) render an empty list.
+ */
+export function scheduledTaskFiles(extInfo: unknown): readonly SessionFileSnapshot[] {
+ if (typeof extInfo !== 'object' || extInfo === null || Array.isArray(extInfo)) {
+ return Object.freeze([]);
+ }
+ return displayOnlySnapshots((extInfo as Record).input_files);
+}
+
+/**
+ * Display-only label for a legacy task's `ext_info.file_path`: just the
+ * basename. The full server path is never rendered into a URL, link or
+ * download affordance. Malformed values yield null so the row can hide.
+ */
+export function legacyFilePathDisplayName(filePath: unknown): string | null {
+ if (typeof filePath !== 'string' || !filePath.trim()) return null;
+ const segments = filePath.split(/[\\/]+/).filter(segment => segment.length > 0);
+ return segments.length > 0 ? segments[segments.length - 1] : null;
+}
diff --git a/src/features/assistant/session-files/reducer.ts b/src/features/assistant/session-files/reducer.ts
new file mode 100644
index 0000000..87fff8c
--- /dev/null
+++ b/src/features/assistant/session-files/reducer.ts
@@ -0,0 +1,359 @@
+/**
+ * Pure reducer for session-file drafts.
+ *
+ * Validation, upload, and preview are orthogonal sub-states:
+ * a validation failure blocks queueing, an upload failure hard-blocks
+ * sending, and a preview failure is only a soft warning.
+ */
+
+import type {
+ CapabilitiesSource,
+ DraftFile,
+ LegacyServerFile,
+ SessionFileSnapshot,
+ SessionFilesSendSnapshot,
+ UploadCapabilities,
+} from './types';
+import { DEFAULT_UPLOAD_CAPABILITIES } from './types';
+
+export interface SessionFilesState {
+ sessionId: string | null;
+ files: DraftFile[];
+ capabilities: UploadCapabilities | null;
+ capabilitiesSource: CapabilitiesSource;
+ /**
+ * Legacy server-preloaded file (example cards). Mutually exclusive with
+ * `files`: the legacy file_path protocol never mixes with session file_ids.
+ */
+ legacyFile: LegacyServerFile | null;
+}
+
+export const initialSessionFilesState = (): SessionFilesState => ({
+ sessionId: null,
+ files: [],
+ capabilities: null,
+ capabilitiesSource: 'fallback',
+ legacyFile: null,
+});
+
+export interface AddInput {
+ clientId: string;
+ file: File;
+ title?: string;
+}
+
+export type SessionFilesAction =
+ | { type: 'bind_session'; sessionId: string }
+ | {
+ type: 'capabilities';
+ capabilities: UploadCapabilities;
+ source: CapabilitiesSource;
+ }
+ | { type: 'add'; inputs: AddInput[] }
+ | { type: 'attach_snapshots'; snapshots: SessionFileSnapshot[] }
+ | { type: 'remove'; clientId: string }
+ | { type: 'upload_start'; clientId: string; attempt: number }
+ | { type: 'upload_progress'; clientId: string; attempt: number; progress: number }
+ | {
+ type: 'upload_done';
+ clientId: string;
+ attempt: number;
+ snapshot: SessionFileSnapshot;
+ }
+ | { type: 'upload_failed'; clientId: string; attempt: number; error: string }
+ | { type: 'upload_cancelled'; clientId: string; attempt: number }
+ | { type: 'retry'; clientId: string }
+ | { type: 'preview_start'; clientId: string }
+ | { type: 'preview_ready'; clientId: string }
+ | { type: 'preview_failed'; clientId: string; error: string }
+ | { type: 'rehydrate'; sessionId: string; snapshots: SessionFileSnapshot[] }
+ | { type: 'set_legacy'; file: LegacyServerFile }
+ | { type: 'clear_legacy' }
+ | { type: 'clear_turn' }
+ | { type: 'reset_session' };
+
+export const identityOf = (file: File): { name: string; size: number; lastModified: number } => ({
+ name: file.name,
+ size: file.size,
+ lastModified: file.lastModified,
+});
+
+const identityKey = (identity: { name: string; size: number; lastModified: number }): string =>
+ `${identity.name}::${identity.size}::${identity.lastModified}`;
+
+const makeDraft = (clientId: string, file: File): DraftFile => ({
+ clientId,
+ file,
+ identity: identityOf(file),
+ validation: { status: 'ok', error: null },
+ upload: { status: 'queued', progress: 0, attempt: 0, error: null },
+ preview: { status: 'idle', error: null },
+ snapshot: null,
+});
+
+const snapshotToDraft = (snapshot: SessionFileSnapshot): DraftFile => {
+ const failed = snapshot.status === 'failed';
+ return {
+ clientId: `server:${snapshot.file_id}`,
+ file: null,
+ identity: { name: snapshot.name, size: snapshot.size, lastModified: 0 },
+ validation: { status: 'ok', error: null },
+ upload: {
+ status: failed ? 'failed' : 'done',
+ progress: failed ? 0 : 1,
+ attempt: 1,
+ error: failed ? (snapshot.error_code ?? 'failed') : null,
+ },
+ preview: {
+ status: snapshot.status === 'ready' ? 'ready' : snapshot.status === 'preview_failed' ? 'preview_failed' : 'idle',
+ error: snapshot.status === 'preview_failed' ? (snapshot.error_code ?? null) : null,
+ },
+ snapshot,
+ };
+};
+
+/**
+ * Plan a batch add against the current drafts and capabilities.
+ * Pure: duplicates (by identity), over-count and over-byte picks become
+ * invalid/blocked drafts; everything else stays queued. Order is preserved.
+ */
+export function planAddDrafts(args: {
+ existing: readonly DraftFile[];
+ files: readonly File[];
+ capabilities: UploadCapabilities;
+ createClientId?: () => string;
+}): DraftFile[] {
+ const { existing, files, capabilities } = args;
+ let fallbackCounter = 0;
+ const createClientId =
+ args.createClientId ??
+ (() =>
+ typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
+ ? crypto.randomUUID()
+ : `draft-${Date.now()}-${fallbackCounter++}`);
+
+ const seen = new Set(existing.map(draft => identityKey(draft.identity)));
+ let acceptedCount = existing.filter(draft => draft.validation.status === 'ok').length;
+ let acceptedBytes = existing
+ .filter(draft => draft.validation.status === 'ok')
+ .reduce((sum, draft) => sum + draft.identity.size, 0);
+
+ return files.map(file => {
+ const draft = makeDraft(createClientId(), file);
+ const key = identityKey(draft.identity);
+ if (seen.has(key)) {
+ draft.validation = { status: 'invalid', error: 'DUPLICATE_FILE' };
+ draft.upload = { ...draft.upload, status: 'blocked' };
+ } else if (file.size > capabilities.max_file_bytes) {
+ draft.validation = { status: 'invalid', error: 'FILE_TOO_LARGE' };
+ draft.upload = { ...draft.upload, status: 'blocked' };
+ } else if (acceptedCount + 1 > capabilities.max_files_per_upload) {
+ draft.validation = { status: 'invalid', error: 'TOO_MANY_FILES' };
+ draft.upload = { ...draft.upload, status: 'blocked' };
+ } else if (acceptedBytes + file.size > capabilities.max_upload_bytes) {
+ draft.validation = { status: 'invalid', error: 'REQUEST_TOO_LARGE' };
+ draft.upload = { ...draft.upload, status: 'blocked' };
+ } else {
+ seen.add(key);
+ acceptedCount += 1;
+ acceptedBytes += file.size;
+ }
+ return draft;
+ });
+}
+
+const mapDraft = (
+ state: SessionFilesState,
+ clientId: string,
+ fn: (draft: DraftFile) => DraftFile,
+): SessionFilesState => {
+ const index = state.files.findIndex(draft => draft.clientId === clientId);
+ if (index < 0) return state; // Stale signal for a removed draft: ignore.
+ const files = state.files.slice();
+ files[index] = fn(files[index]);
+ return { ...state, files };
+};
+
+export function sessionFilesReducer(state: SessionFilesState, action: SessionFilesAction): SessionFilesState {
+ switch (action.type) {
+ case 'bind_session':
+ return { ...state, sessionId: action.sessionId };
+
+ case 'capabilities':
+ return { ...state, capabilities: action.capabilities, capabilitiesSource: action.source };
+
+ case 'add': {
+ // Legacy file_path and session file_ids are mutually exclusive: while a
+ // legacy example file is staged, local picks are refused wholesale.
+ if (state.legacyFile) return state;
+ const capabilities = state.capabilities ?? DEFAULT_UPLOAD_CAPABILITIES;
+ const planned = planAddDrafts({
+ existing: state.files,
+ files: action.inputs.map(input => input.file),
+ capabilities,
+ });
+ // Caller-supplied clientIds win (planned order matches input order).
+ const drafts = planned.map((draft, index) => {
+ const title = action.inputs[index]?.title?.trim();
+ return {
+ ...draft,
+ clientId: action.inputs[index]?.clientId ?? draft.clientId,
+ title: title || undefined,
+ };
+ });
+ // The reducer stays authoritative on duplicates, even if the caller
+ // planned against stale state.
+ const seen = new Set(state.files.map(draft => identityKey(draft.identity)));
+ const accepted = drafts.filter(draft => {
+ const key = identityKey(draft.identity);
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+ return { ...state, files: [...state.files, ...accepted] };
+ }
+
+ case 'attach_snapshots': {
+ if (state.legacyFile) return state;
+ const existing = new Set(
+ state.files.map(draft => draft.snapshot?.file_id).filter((id): id is string => Boolean(id)),
+ );
+ const extra = action.snapshots
+ .filter(snapshot => snapshot.status !== 'deleted' && !existing.has(snapshot.file_id))
+ .map(snapshotToDraft);
+ if (extra.length === 0) return state;
+ return { ...state, files: [...state.files, ...extra] };
+ }
+
+ case 'remove':
+ return { ...state, files: state.files.filter(draft => draft.clientId !== action.clientId) };
+
+ case 'upload_start':
+ return mapDraft(state, action.clientId, draft =>
+ draft.upload.status !== 'queued'
+ ? draft
+ : patchUpload(draft, { status: 'uploading', attempt: action.attempt, progress: 0, error: null }),
+ );
+
+ case 'upload_progress':
+ return mapDraft(state, action.clientId, draft =>
+ draft.upload.status !== 'uploading' || draft.upload.attempt !== action.attempt
+ ? draft
+ : patchUpload(draft, { progress: Math.min(1, Math.max(0, action.progress)) }),
+ );
+
+ case 'upload_done':
+ return mapDraft(state, action.clientId, draft =>
+ draft.upload.status !== 'uploading' || draft.upload.attempt !== action.attempt
+ ? draft
+ : {
+ ...patchUpload(draft, { status: 'done', progress: 1, error: null }),
+ snapshot: action.snapshot,
+ },
+ );
+
+ case 'upload_failed':
+ return mapDraft(state, action.clientId, draft =>
+ draft.upload.status !== 'uploading' || draft.upload.attempt !== action.attempt
+ ? draft
+ : patchUpload(draft, { status: 'failed', error: action.error }),
+ );
+
+ case 'upload_cancelled':
+ return mapDraft(state, action.clientId, draft =>
+ (draft.upload.status !== 'uploading' && draft.upload.status !== 'queued') ||
+ draft.upload.attempt !== action.attempt
+ ? draft
+ : patchUpload(draft, { status: 'cancelled', progress: 0 }),
+ );
+
+ case 'retry':
+ return mapDraft(state, action.clientId, draft =>
+ draft.upload.status !== 'failed' ? draft : patchUpload(draft, { status: 'queued', progress: 0, error: null }),
+ );
+
+ case 'preview_start':
+ return mapDraft(state, action.clientId, draft =>
+ draft.upload.status !== 'done' ? draft : patchPreview(draft, { status: 'loading', error: null }),
+ );
+
+ case 'preview_ready':
+ return mapDraft(state, action.clientId, draft =>
+ draft.preview.status !== 'loading' ? draft : patchPreview(draft, { status: 'ready', error: null }),
+ );
+
+ case 'preview_failed':
+ return mapDraft(state, action.clientId, draft =>
+ draft.preview.status !== 'loading'
+ ? draft
+ : patchPreview(draft, { status: 'preview_failed', error: action.error }),
+ );
+
+ case 'rehydrate': {
+ const drafts = action.snapshots
+ .filter(snapshot => snapshot.status !== 'deleted')
+ .slice()
+ .sort((a, b) => a.ordinal - b.ordinal)
+ .map(snapshotToDraft);
+ return { ...state, sessionId: action.sessionId, files: drafts, legacyFile: null };
+ }
+
+ case 'set_legacy': {
+ // Mutually exclusive with local drafts; re-staging is idempotent —
+ // the first staged example file wins until explicitly cleared.
+ if (state.files.length > 0 || state.legacyFile) return state;
+ return { ...state, legacyFile: { ...action.file } };
+ }
+
+ case 'clear_legacy':
+ return { ...state, legacyFile: null };
+
+ case 'clear_turn':
+ return { ...state, files: [], legacyFile: null };
+
+ case 'reset_session':
+ return { ...state, sessionId: null, files: [], legacyFile: null };
+
+ default:
+ return state;
+ }
+}
+
+const patchUpload = (draft: DraftFile, patch: Partial): DraftFile => ({
+ ...draft,
+ upload: { ...draft.upload, ...patch },
+});
+
+const patchPreview = (draft: DraftFile, patch: Partial): DraftFile => ({
+ ...draft,
+ preview: { ...draft.preview, ...patch },
+});
+
+/** Upload failures are the only hard gate for sending. */
+export function hasHardFailures(state: SessionFilesState): boolean {
+ return state.files.some(draft => draft.upload.status === 'failed');
+}
+
+/**
+ * Build the immutable, insertion-ordered send snapshot.
+ * Throws when any draft has a hard (upload) failure. A staged legacy file is
+ * carried through so the send path can resend it via the legacy file_path
+ * protocol; the reducer's mixing guards already keep legacy and drafts
+ * mutually exclusive, so the two never appear together here.
+ */
+export function buildSendSnapshot(state: SessionFilesState, sessionId: string): SessionFilesSendSnapshot {
+ const failed = state.files.filter(draft => draft.upload.status === 'failed');
+ if (failed.length > 0) {
+ const names = failed.map(draft => draft.identity.name).join(', ');
+ throw new Error(`SESSION_FILES_HARD_FAILURE: resolve failed uploads before send (${names})`);
+ }
+ const uploaded = state.files.filter(
+ (draft): draft is DraftFile & { snapshot: SessionFileSnapshot } => draft.snapshot !== null,
+ );
+ const fileIds = Object.freeze(uploaded.map(draft => draft.snapshot.file_id));
+ const snapshotById = Object.freeze(
+ Object.fromEntries(uploaded.map(draft => [draft.snapshot.file_id, Object.freeze({ ...draft.snapshot })])),
+ );
+ const legacyFile = state.legacyFile ? Object.freeze({ ...state.legacyFile }) : null;
+ return Object.freeze({ sessionId, fileIds, snapshotById, legacyFile });
+}
diff --git a/src/features/assistant/session-files/types.ts b/src/features/assistant/session-files/types.ts
new file mode 100644
index 0000000..5e3126d
--- /dev/null
+++ b/src/features/assistant/session-files/types.ts
@@ -0,0 +1,195 @@
+/**
+ * Types for the session-files module.
+ *
+ * Wire types mirror Kamidso product file API (/api/kamidso/v1/files).
+ * Local draft types model the client-side upload/preview state machines.
+ */
+
+/** Kamidso canonical file record (public fields only). */
+export interface KamidsoFile {
+ id: string
+ name: string
+ size_bytes: number
+ mime_type: string
+ status: 'processing' | 'ready' | 'failed'
+ created_at: string
+ title?: string
+ original_name?: string
+ kind?: string
+ extension?: string
+}
+
+export function kamidsoFileToSnapshot(file: KamidsoFile, ordinal = 0): SessionFileSnapshot {
+ const statusMap: Record = {
+ processing: 'uploading',
+ ready: 'ready',
+ failed: 'failed',
+ }
+ return Object.freeze({
+ file_id: String(file.id || ''),
+ name: file.title?.trim() || file.name,
+ size: file.size_bytes,
+ media_type: file.mime_type,
+ kind: file.kind || 'file',
+ status: statusMap[file.status] ?? 'ready',
+ ordinal,
+ error_code: file.status === 'failed' ? 'UPLOAD_FAILED' : null,
+ })
+}
+
+/** Client-side lifecycle status for drafts and server snapshots. */
+export type SessionFileStatus = 'uploading' | 'inspecting' | 'ready' | 'preview_failed' | 'failed' | 'deleted';
+
+/** Backend snapshot of one session file (SessionFileResponse). */
+export interface SessionFileSnapshot {
+ file_id: string;
+ name: string;
+ size: number;
+ media_type: string;
+ kind: string;
+ status: SessionFileStatus;
+ /** Upload order within the scope. */
+ ordinal: number;
+ error_code?: string | null;
+}
+
+/** Backend preview payload (SessionFilePreviewResponse); adapter only. */
+export interface SessionFilePreviewSnapshot {
+ file_id: string;
+ name: string;
+ media_type: string;
+ kind: string;
+ status: SessionFileStatus;
+ truncated: boolean;
+ preview: Record;
+ error_code?: string | null;
+}
+
+/** Server-owned upload limits (SessionFileCapabilitiesResponse). */
+export interface UploadCapabilities {
+ max_files_per_upload: number;
+ max_file_bytes: number;
+ max_upload_bytes: number;
+ max_owner_bytes: number;
+ upload_request_timeout_seconds: number;
+ upload_concurrency: number;
+ supported_extensions: string[];
+}
+
+/** Conservative defaults mirroring ServeConfig; used when /capabilities is unreachable. */
+export const DEFAULT_UPLOAD_CAPABILITIES: UploadCapabilities = {
+ max_files_per_upload: 20,
+ max_file_bytes: 100 * 1024 * 1024,
+ max_upload_bytes: 500 * 1024 * 1024,
+ max_owner_bytes: 1024 * 1024 * 1024,
+ upload_request_timeout_seconds: 180,
+ upload_concurrency: 3,
+ supported_extensions: [
+ '.csv',
+ '.tsv',
+ '.xls',
+ '.xlsx',
+ '.json',
+ '.jsonl',
+ '.parquet',
+ '.pdf',
+ '.doc',
+ '.docx',
+ '.pptx',
+ '.md',
+ '.txt',
+ ],
+};
+
+export type CapabilitiesSource = 'server' | 'fallback';
+
+export interface CapabilitiesResult {
+ capabilities: UploadCapabilities;
+ source: CapabilitiesSource;
+ /** Present when the server could not be honored and defaults were used. */
+ error?: unknown;
+}
+
+/** Adapter seam implemented by api.ts; injected into the hook for testability. */
+export interface SessionFilesApi {
+ uploadFiles(params: {
+ sessionId: string;
+ files: readonly File[];
+ title?: string;
+ signal?: AbortSignal;
+ onProgress?: (ratio: number) => void;
+ timeoutMs?: number;
+ }): Promise;
+ listFiles(sessionId: string): Promise;
+ deleteFile(sessionId: string, fileId: string): Promise;
+ fetchCapabilities(): Promise;
+ /** Optional: preview endpoints exist server-side; the adapter keeps the seam ready. */
+ previewFile?(sessionId: string, fileId: string): Promise;
+}
+
+/** Identity used to detect duplicate local picks. */
+export interface FileIdentity {
+ name: string;
+ size: number;
+ lastModified: number;
+}
+
+/** Client-side upload state machine (orthogonal to validation and preview). */
+export type UploadPhase =
+ | 'queued'
+ | 'uploading'
+ | 'done'
+ | 'failed'
+ | 'cancelled'
+ /** Validation rejected the file; it never enters the upload queue. */
+ | 'blocked';
+
+/** Client-side preview state machine (orthogonal to upload). */
+export type PreviewPhase = 'idle' | 'loading' | 'ready' | 'preview_failed';
+
+export type ValidationStatus = 'ok' | 'invalid';
+
+export interface DraftFile {
+ /** Stable client-side id; kept across retries. */
+ clientId: string;
+ /** Local bytes; null for drafts rehydrated from the server list. */
+ file: File | null;
+ /** User-facing title; the original filename stays on identity/file. */
+ title?: string;
+ identity: FileIdentity;
+ validation: { status: ValidationStatus; error: string | null };
+ upload: {
+ status: UploadPhase;
+ /** 0..1 */
+ progress: number;
+ /** Monotonic per-draft attempt token; stale completions carry an old token. */
+ attempt: number;
+ error: string | null;
+ };
+ preview: { status: PreviewPhase; error: string | null };
+ /** Server snapshot once uploaded (or rehydrated). */
+ snapshot: SessionFileSnapshot | null;
+}
+
+/**
+ * Legacy server-side file preloaded by an example card. The file already
+ * exists on the server (identified only by its path), so it is displayed
+ * read-only in the composer rail and resent verbatim via the legacy
+ * `ext_info.file_path` protocol — mutually exclusive with `file_ids`.
+ */
+export interface LegacyServerFile {
+ name: string;
+ size: number;
+ media_type: string;
+ /** Server-side path echoed back as ext_info.file_path (legacy protocol). */
+ file_path: string;
+}
+
+/** Immutable, ordered payload handed to the send flow. */
+export interface SessionFilesSendSnapshot {
+ readonly sessionId: string;
+ readonly fileIds: readonly string[];
+ readonly snapshotById: Readonly>;
+ /** Legacy staged file for this send; mutually exclusive with `fileIds`. */
+ readonly legacyFile?: LegacyServerFile | null;
+}
diff --git a/src/features/assistant/session-files/upload-queue.ts b/src/features/assistant/session-files/upload-queue.ts
new file mode 100644
index 0000000..6f139e8
--- /dev/null
+++ b/src/features/assistant/session-files/upload-queue.ts
@@ -0,0 +1,185 @@
+/**
+ * In-process upload command queue.
+ *
+ * - Bounded concurrency (default MAX_UPLOAD_CONCURRENCY).
+ * - Stable FIFO start order; task results always resolve (never reject),
+ * so one failure can never take down its siblings.
+ * - Aborted tasks report 'cancelled' and are never treated as retryable
+ * network errors; retry requeues failed tasks only.
+ */
+
+export const MAX_UPLOAD_CONCURRENCY = 3;
+
+export type QueueSettledStatus = 'done' | 'failed' | 'cancelled';
+
+export interface QueueTaskResult {
+ clientId: string;
+ status: QueueSettledStatus;
+ value?: T;
+ error?: unknown;
+}
+
+export interface UploadQueueOptions {
+ concurrency?: number;
+}
+
+type TaskStatus = 'pending' | 'running' | QueueSettledStatus;
+
+interface QueueTask {
+ clientId: string;
+ run: (signal: AbortSignal) => Promise;
+ controller: AbortController;
+ status: TaskStatus;
+ aborted: boolean;
+ result?: QueueTaskResult;
+ promise: Promise>;
+ resolve: (result: QueueTaskResult) => void;
+}
+
+function makeTask(clientId: string, run: (signal: AbortSignal) => Promise): QueueTask {
+ const task: Partial> = {
+ clientId,
+ run,
+ controller: new AbortController(),
+ status: 'pending',
+ aborted: false,
+ result: undefined,
+ };
+ task.promise = new Promise>(resolve => {
+ task.resolve = resolve;
+ });
+ return task as QueueTask;
+}
+
+export class UploadQueue {
+ private readonly concurrency: number;
+ private readonly pending: QueueTask[] = [];
+ private readonly running = new Set>();
+ private readonly tasks = new Map>();
+
+ constructor(options: UploadQueueOptions = {}) {
+ const concurrency = options.concurrency ?? MAX_UPLOAD_CONCURRENCY;
+ this.concurrency = Math.max(1, Math.floor(concurrency));
+ }
+
+ get activeCount(): number {
+ return this.running.size;
+ }
+
+ get pendingCount(): number {
+ return this.pending.length;
+ }
+
+ result(clientId: string): QueueTaskResult | undefined {
+ return this.tasks.get(clientId)?.result;
+ }
+
+ /** Enqueue a task; the returned promise always resolves (never rejects). */
+ enqueue(clientId: string, run: (signal: AbortSignal) => Promise): Promise> {
+ const task = makeTask(clientId, run);
+ this.tasks.set(clientId, task);
+ this.pending.push(task);
+ this.pump();
+ return task.promise;
+ }
+
+ private pump(): void {
+ while (this.running.size < this.concurrency && this.pending.length > 0) {
+ const task = this.pending.shift()!;
+ this.running.add(task);
+ task.status = 'running';
+ void this.execute(task);
+ }
+ }
+
+ private async execute(task: QueueTask): Promise {
+ try {
+ const value = await task.run(task.controller.signal);
+ this.finish(task, { clientId: task.clientId, status: task.aborted ? 'cancelled' : 'done', value });
+ } catch (error) {
+ // An abort is never reported as a retryable failure, whatever the
+ // underlying run() rejected with.
+ this.finish(
+ task,
+ task.aborted
+ ? { clientId: task.clientId, status: 'cancelled' }
+ : { clientId: task.clientId, status: 'failed', error },
+ );
+ }
+ }
+
+ private finish(task: QueueTask, result: QueueTaskResult): void {
+ task.status = result.status;
+ task.result = result;
+ this.running.delete(task);
+ task.resolve(result);
+ this.pump();
+ }
+
+ /**
+ * Cancel a pending or running task. Pending tasks never run; running
+ * tasks get an aborted signal and settle as 'cancelled' (never 'failed').
+ * Returns false when the clientId is unknown.
+ */
+ cancel(clientId: string): boolean {
+ const pendingIndex = this.pending.findIndex(task => task.clientId === clientId);
+ if (pendingIndex >= 0) {
+ const [task] = this.pending.splice(pendingIndex, 1);
+ task.aborted = true;
+ task.controller.abort();
+ this.finish(task, { clientId, status: 'cancelled' });
+ return true;
+ }
+ for (const task of this.running) {
+ if (task.clientId !== clientId) continue;
+ task.aborted = true;
+ task.controller.abort();
+ return true;
+ }
+ return false;
+ }
+
+ /** Cancel everything pending or running (e.g. on turn cleanup). */
+ cancelAll(): void {
+ for (const task of [...this.pending, ...this.running]) {
+ this.cancel(task.clientId);
+ }
+ }
+
+ /**
+ * Requeue one failed task with a fresh controller and promise.
+ * Returns null when the task is unknown or not in 'failed' state —
+ * cancelled tasks are explicitly not retryable.
+ */
+ retry(clientId: string): Promise> | null {
+ const task = this.tasks.get(clientId);
+ if (!task || task.result?.status !== 'failed') return null;
+ task.controller = new AbortController();
+ task.aborted = false;
+ task.status = 'pending';
+ task.result = undefined;
+ task.promise = new Promise>(resolve => {
+ task.resolve = resolve;
+ });
+ this.pending.push(task);
+ this.pump();
+ return task.promise;
+ }
+
+ /** Requeue all failed tasks, preserving FIFO order. Returns their clientIds. */
+ retryFailed(): string[] {
+ const retried: string[] = [];
+ for (const task of this.tasks.values()) {
+ if (task.result?.status !== 'failed') continue;
+ if (this.retry(task.clientId)) retried.push(task.clientId);
+ }
+ return retried;
+ }
+
+ /** Resolve once every queued task has settled (failures included). */
+ async drain(): Promise {
+ while (this.pending.length > 0 || this.running.size > 0) {
+ await Promise.allSettled([...this.pending, ...this.running].map(task => task.promise));
+ }
+ }
+}
diff --git a/src/features/assistant/session-files/use-session-files.ts b/src/features/assistant/session-files/use-session-files.ts
new file mode 100644
index 0000000..e311d50
--- /dev/null
+++ b/src/features/assistant/session-files/use-session-files.ts
@@ -0,0 +1,425 @@
+/**
+ * React orchestration for session-file drafts.
+ *
+ * State lives in the pure reducer; the network lives behind the injected
+ * SessionFilesApi seam; concurrency is owned by UploadQueue. Everything that
+ * must survive re-renders (latest state, api, attempt tokens, capabilities
+ * promise) lives in refs so captured callbacks never read stale closures.
+ */
+
+import { useCallback, useEffect, useReducer, useRef } from 'react';
+
+import * as reducerModule from './reducer';
+import type {
+ DraftFile,
+ LegacyServerFile,
+ SessionFileSnapshot,
+ SessionFilesApi,
+ SessionFilesSendSnapshot,
+ UploadCapabilities,
+} from './types';
+import type { QueueSettledStatus } from './upload-queue';
+import { MAX_UPLOAD_CONCURRENCY, UploadQueue } from './upload-queue';
+
+export interface UseSessionFilesOptions {
+ api: SessionFilesApi;
+ concurrency?: number;
+ /** Injectable for deterministic tests; defaults to crypto.randomUUID. */
+ createClientId?: () => string;
+}
+
+export type StageLegacyForSendResult = { ok: true; snapshot: SessionFilesSendSnapshot } | { ok: false; error: string };
+
+const isValidLegacyFile = (file: LegacyServerFile): boolean =>
+ !!file && typeof file.file_path === 'string' && !!file.file_path.trim() && !!file.name?.trim();
+
+const isSameLegacyFile = (left: LegacyServerFile, right: LegacyServerFile): boolean =>
+ left.name === right.name &&
+ left.size === right.size &&
+ left.media_type === right.media_type &&
+ left.file_path === right.file_path;
+
+export interface UseSessionFiles {
+ files: readonly DraftFile[];
+ sessionId: string | null;
+ capabilities: UploadCapabilities | null;
+ capabilitiesSource: 'server' | 'fallback';
+ hasHardFailures: boolean;
+ isUploading: boolean;
+ /**
+ * Legacy server-preloaded file staged by an example card (read-only in the
+ * rail). Mutually exclusive with local drafts: while staged, `addFiles` is
+ * refused; while drafts exist, `stageLegacyForSend` is refused.
+ */
+ legacyFile: LegacyServerFile | null;
+ addFiles: (incoming: File[] | ArrayLike, sessionId: string, options?: { titles?: Array }) => Promise;
+ /** Attach already-uploaded workspace files without uploading again. */
+ attachLibraryFiles: (snapshots: SessionFileSnapshot[], sessionId?: string | null) => void;
+ remove: (clientId: string) => void;
+ cancel: (clientId: string) => void;
+ retryFailed: () => string[];
+ /**
+ * Atomically stage a legacy server-preloaded example and return the frozen
+ * snapshot for this send. The returned snapshot is authoritative even when
+ * React has not committed the rail-state dispatch yet.
+ */
+ stageLegacyForSend: (file: LegacyServerFile, sessionId: string) => StageLegacyForSendResult;
+ /** Unstage the legacy example file without touching drafts. */
+ clearLegacyFile: () => void;
+ /** Wait for in-flight uploads and return the frozen, ordered send payload. */
+ prepare: (sessionId: string) => Promise;
+ /** Rebuild drafts from the server list for a conversation (switch-resilient). */
+ rehydrateFromServer: (sessionId: string) => Promise;
+ /** Clear the current turn's drafts while keeping the session scope. */
+ clearTurn: () => void;
+ /** Clear task-local files and detach the current session scope. */
+ resetSession: () => void;
+}
+
+export function useSessionFiles(options: UseSessionFilesOptions): UseSessionFiles {
+ const { api, concurrency = MAX_UPLOAD_CONCURRENCY } = options;
+ const [state, dispatch] = useReducer(
+ reducerModule.sessionFilesReducer,
+ undefined,
+ reducerModule.initialSessionFilesState,
+ );
+
+ const stateRef = useRef(state);
+ stateRef.current = state;
+
+ const apiRef = useRef(api);
+ apiRef.current = api;
+
+ const defaultClientId = () =>
+ typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
+ ? crypto.randomUUID()
+ : `draft-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ const createClientIdRef = useRef(options.createClientId ?? defaultClientId);
+ createClientIdRef.current = options.createClientId ?? defaultClientId;
+
+ const queueRef = useRef | null>(null);
+ if (!queueRef.current) {
+ queueRef.current = new UploadQueue({ concurrency });
+ }
+
+ // Monotonic attempt tokens per draft resist stale completions.
+ const attemptsRef = useRef(new Map());
+ // Scope changes (conversation switch/new task) invalidate late async work.
+ const scopeGenerationRef = useRef(0);
+ const capabilitiesPromiseRef = useRef | null>(null);
+
+ // Abort every in-flight upload when the owning component unmounts so
+ // requests do not run to completion (wasting bandwidth and leaving orphan
+ // files) after the UI that owns them is gone.
+ useEffect(
+ () => () => {
+ queueRef.current?.cancelAll();
+ attemptsRef.current.clear();
+ },
+ [],
+ );
+
+ const settleUpload = useCallback(
+ (
+ draft: DraftFile,
+ sessionId: string,
+ attempt: number,
+ promise: Promise<{
+ clientId: string;
+ status: QueueSettledStatus;
+ value?: SessionFileSnapshot;
+ error?: unknown;
+ }>,
+ ) => {
+ void promise.then(result => {
+ if (attemptsRef.current.get(draft.clientId) !== attempt) return; // stale attempt
+ if (result.status === 'done' && result.value) {
+ const snapshot = result.value;
+ dispatch({ type: 'upload_done', clientId: draft.clientId, attempt, snapshot });
+ const previewFile = apiRef.current.previewFile;
+ if (!previewFile) return;
+ // Preview is orthogonal: a failure here is only a soft warning.
+ dispatch({ type: 'preview_start', clientId: draft.clientId });
+ void previewFile
+ .call(apiRef.current, sessionId, snapshot.file_id)
+ .then(() => dispatch({ type: 'preview_ready', clientId: draft.clientId }))
+ .catch((error: unknown) =>
+ dispatch({
+ type: 'preview_failed',
+ clientId: draft.clientId,
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ );
+ return;
+ }
+ if (result.status === 'failed') {
+ dispatch({
+ type: 'upload_failed',
+ clientId: draft.clientId,
+ attempt,
+ error: result.error instanceof Error ? result.error.message : String(result.error),
+ });
+ }
+ // 'cancelled' results are already reflected by the cancel action.
+ });
+ },
+ [],
+ );
+
+ const launchUpload = useCallback(
+ (draft: DraftFile, sessionId: string) => {
+ const file = draft.file;
+ if (!file || draft.validation.status !== 'ok') return;
+ const queue = queueRef.current!;
+ const attempt = (attemptsRef.current.get(draft.clientId) ?? 0) + 1;
+ attemptsRef.current.set(draft.clientId, attempt);
+ dispatch({ type: 'upload_start', clientId: draft.clientId, attempt });
+
+ const uploadTimeoutSeconds = stateRef.current.capabilities?.upload_request_timeout_seconds;
+ const promise = queue.enqueue(draft.clientId, async signal => {
+ const snapshots = await apiRef.current.uploadFiles({
+ sessionId,
+ files: [file],
+ title: draft.title,
+ signal,
+ timeoutMs: uploadTimeoutSeconds != null ? uploadTimeoutSeconds * 1000 : undefined,
+ onProgress: ratio => {
+ if (attemptsRef.current.get(draft.clientId) !== attempt) return;
+ dispatch({
+ type: 'upload_progress',
+ clientId: draft.clientId,
+ attempt,
+ progress: ratio,
+ });
+ },
+ });
+ const snapshot = snapshots[0];
+ if (!snapshot) throw new Error('Upload response contained no file snapshot.');
+ return snapshot;
+ });
+ settleUpload(draft, sessionId, attempt, promise);
+ },
+ [settleUpload],
+ );
+
+ const ensureCapabilities = useCallback((): Promise => {
+ const existing = stateRef.current.capabilities;
+ if (existing) return Promise.resolve(existing);
+ if (!capabilitiesPromiseRef.current) {
+ capabilitiesPromiseRef.current = apiRef.current.fetchCapabilities().then(result => {
+ const action = { type: 'capabilities' as const, capabilities: result.capabilities, source: result.source };
+ stateRef.current = reducerModule.sessionFilesReducer(stateRef.current, action);
+ dispatch(action);
+ return result.capabilities;
+ });
+ }
+ return capabilitiesPromiseRef.current;
+ }, []);
+
+ const addFiles = useCallback(
+ async (
+ incoming: File[] | ArrayLike,
+ sessionId: string,
+ options?: { titles?: Array },
+ ): Promise => {
+ const files = Array.from(incoming as ArrayLike);
+ if (files.length === 0) return;
+ const generation = scopeGenerationRef.current;
+ // Legacy example file staged: refuse mixing with the file_ids protocol.
+ if (stateRef.current.legacyFile) return;
+ if (stateRef.current.sessionId !== sessionId) {
+ const action = { type: 'bind_session' as const, sessionId };
+ stateRef.current = reducerModule.sessionFilesReducer(stateRef.current, action);
+ dispatch(action);
+ }
+ const capabilities = await ensureCapabilities();
+ if (scopeGenerationRef.current !== generation) return;
+ // Capabilities may require a network round-trip. A legacy example can
+ // be staged while that request is in flight, so re-check the protocol
+ // intent before planning drafts or launching any uploads.
+ if (stateRef.current.legacyFile) return;
+ const drafts = reducerModule.planAddDrafts({
+ existing: stateRef.current.files,
+ files,
+ capabilities,
+ createClientId: () => createClientIdRef.current(),
+ }).map((draft, index) => {
+ const title = options?.titles?.[index]?.trim();
+ return title ? { ...draft, title } : draft;
+ });
+ if (drafts.length === 0) return;
+ const action = {
+ type: 'add' as const,
+ inputs: drafts.map(draft => ({
+ clientId: draft.clientId,
+ file: draft.file!,
+ title: draft.title,
+ })),
+ };
+ const current = stateRef.current;
+ const projected = reducerModule.sessionFilesReducer(current, action);
+ const existingClientIds = new Set(current.files.map(draft => draft.clientId));
+ const acceptedClientIds = new Set(
+ projected.files.filter(draft => !existingClientIds.has(draft.clientId)).map(draft => draft.clientId),
+ );
+ stateRef.current = projected;
+ dispatch(action);
+ for (const draft of drafts) {
+ if (acceptedClientIds.has(draft.clientId)) {
+ launchUpload(draft, sessionId);
+ }
+ }
+ },
+ [dispatch, ensureCapabilities, launchUpload],
+ );
+
+ const stageLegacyForSend = useCallback((file: LegacyServerFile, sessionId: string): StageLegacyForSendResult => {
+ if (!isValidLegacyFile(file)) {
+ return { ok: false, error: 'LEGACY_FILE_INVALID' };
+ }
+ if (!sessionId?.trim()) {
+ return { ok: false, error: 'SESSION_SCOPE_INVALID: a conversation id is required' };
+ }
+
+ const current = stateRef.current;
+ if (current.files.length > 0) {
+ // file_path and file_ids are mutually exclusive server-side.
+ return {
+ ok: false,
+ error: 'SESSION_FILES_MIXED_PROTOCOL: remove local uploads before staging a legacy example file',
+ };
+ }
+ if (current.legacyFile && !isSameLegacyFile(current.legacyFile, file)) {
+ return {
+ ok: false,
+ error: 'SESSION_FILES_LEGACY_CONFLICT: remove the staged example file before selecting another one',
+ };
+ }
+
+ // Project the exact reducer state synchronously and build the immutable
+ // send snapshot from that projection. React dispatch remains responsible
+ // only for committing the same state to the attachment rail.
+ let projected = current;
+ if (projected.sessionId !== sessionId) {
+ projected = reducerModule.sessionFilesReducer(projected, { type: 'bind_session', sessionId });
+ }
+ if (!projected.legacyFile) {
+ projected = reducerModule.sessionFilesReducer(projected, { type: 'set_legacy', file });
+ }
+
+ // Keep callback reads authoritative during React's batched window. The
+ // queued reducer actions below commit the identical projection to the UI.
+ stateRef.current = projected;
+
+ if (current.sessionId !== sessionId) {
+ dispatch({ type: 'bind_session', sessionId });
+ }
+ if (!current.legacyFile) {
+ dispatch({ type: 'set_legacy', file });
+ }
+
+ return { ok: true, snapshot: reducerModule.buildSendSnapshot(projected, sessionId) };
+ }, []);
+
+ const clearLegacyFile = useCallback(() => {
+ dispatch({ type: 'clear_legacy' });
+ }, []);
+
+ const attachLibraryFiles = useCallback((snapshots: SessionFileSnapshot[], sessionId?: string | null) => {
+ const ready = snapshots.filter((snapshot) => snapshot.file_id && snapshot.status !== 'deleted');
+ if (ready.length === 0 || stateRef.current.legacyFile) return;
+ let projected = stateRef.current;
+ if (sessionId && projected.sessionId !== sessionId) {
+ const bind = { type: 'bind_session' as const, sessionId };
+ projected = reducerModule.sessionFilesReducer(projected, bind);
+ dispatch(bind);
+ }
+ const action = { type: 'attach_snapshots' as const, snapshots: ready };
+ projected = reducerModule.sessionFilesReducer(projected, action);
+ stateRef.current = projected;
+ dispatch(action);
+ }, []);
+
+ const remove = useCallback((clientId: string) => {
+ queueRef.current!.cancel(clientId);
+ attemptsRef.current.delete(clientId);
+ dispatch({ type: 'remove', clientId });
+ }, []);
+
+ const cancel = useCallback((clientId: string) => {
+ queueRef.current!.cancel(clientId);
+ dispatch({ type: 'upload_cancelled', clientId, attempt: attemptsRef.current.get(clientId) ?? 0 });
+ }, []);
+
+ const retryFailed = useCallback((): string[] => {
+ const sessionId = stateRef.current.sessionId;
+ const failed = stateRef.current.files.filter(draft => draft.upload.status === 'failed');
+ for (const draft of failed) {
+ dispatch({ type: 'retry', clientId: draft.clientId });
+ if (sessionId) launchUpload(draft, sessionId);
+ }
+ return failed.map(draft => draft.clientId);
+ }, [launchUpload]);
+
+ const prepare = useCallback(async (sessionId: string): Promise => {
+ const bound = stateRef.current.sessionId;
+ if (bound && bound !== sessionId) {
+ throw new Error(`SESSION_SCOPE_MISMATCH: bound to ${bound}, requested ${sessionId}`);
+ }
+ if (!bound) {
+ const action = { type: 'bind_session' as const, sessionId };
+ stateRef.current = reducerModule.sessionFilesReducer(stateRef.current, action);
+ dispatch(action);
+ }
+ await queueRef.current!.drain();
+ return reducerModule.buildSendSnapshot(stateRef.current, sessionId);
+ }, []);
+
+ const rehydrateFromServer = useCallback(async (sessionId: string): Promise => {
+ const generation = ++scopeGenerationRef.current;
+ queueRef.current!.cancelAll();
+ attemptsRef.current.clear();
+ const snapshots = await apiRef.current.listFiles(sessionId);
+ if (scopeGenerationRef.current !== generation) return snapshots;
+ const action = { type: 'rehydrate' as const, sessionId, snapshots };
+ stateRef.current = reducerModule.sessionFilesReducer(stateRef.current, action);
+ dispatch(action);
+ return snapshots;
+ }, []);
+
+ const clearTurn = useCallback(() => {
+ queueRef.current!.cancelAll();
+ attemptsRef.current.clear();
+ dispatch({ type: 'clear_turn' });
+ }, []);
+
+ const resetSession = useCallback(() => {
+ scopeGenerationRef.current += 1;
+ queueRef.current!.cancelAll();
+ attemptsRef.current.clear();
+ const action = { type: 'reset_session' as const };
+ stateRef.current = reducerModule.sessionFilesReducer(stateRef.current, action);
+ dispatch(action);
+ }, []);
+
+ return {
+ files: state.files,
+ sessionId: state.sessionId,
+ capabilities: state.capabilities,
+ capabilitiesSource: state.capabilitiesSource,
+ hasHardFailures: reducerModule.hasHardFailures(state),
+ isUploading: state.files.some(draft => draft.upload.status === 'uploading' || draft.upload.status === 'queued'),
+ legacyFile: state.legacyFile,
+ addFiles,
+ attachLibraryFiles,
+ stageLegacyForSend,
+ clearLegacyFile,
+ remove,
+ cancel,
+ retryFailed,
+ prepare,
+ rehydrateFromServer,
+ clearTurn,
+ resetSession,
+ };
+}
diff --git a/src/features/assistant/state/runState.ts b/src/features/assistant/state/runState.ts
new file mode 100644
index 0000000..6afcd5b
--- /dev/null
+++ b/src/features/assistant/state/runState.ts
@@ -0,0 +1,55 @@
+import type { KamidsoArtifact, ExecutionStep, PendingQuestion, PlanTask } from '../../../contracts/run'
+import type { RunStatus } from '../../../contracts/common'
+import type { KamidsoError } from '../../../contracts/errors'
+
+export type ExecutionOutput = {
+ output_type: string
+ content: unknown
+}
+
+export type ExecutionState = {
+ steps: ExecutionStep[]
+ outputs: Record
+ activeStepId: string | null
+ stepThoughts: Record
+}
+
+export type RunState = {
+ id: string | null
+ status: RunStatus | 'idle'
+ plan: PlanTask[]
+ steps: ExecutionStep[]
+ artifacts: KamidsoArtifact[]
+ htmlByViewId: Record
+ thinkingByViewId: Record
+ answer: string
+ question: PendingQuestion | null
+ error: KamidsoError | null
+ executionByViewId: Record
+ activeViewId: string | null
+ loading: boolean
+ lastEventSequence: number
+}
+
+export function emptyExecution(): ExecutionState {
+ return { steps: [], outputs: {}, activeStepId: null, stepThoughts: {} }
+}
+
+export function initialRunState(): RunState {
+ return {
+ id: null,
+ status: 'idle',
+ plan: [],
+ steps: [],
+ artifacts: [],
+ htmlByViewId: {},
+ thinkingByViewId: {},
+ answer: '',
+ question: null,
+ error: null,
+ executionByViewId: {},
+ activeViewId: null,
+ loading: false,
+ lastEventSequence: 0,
+ }
+}
diff --git a/src/features/assistant/stream/downloadFiles.test.ts b/src/features/assistant/stream/downloadFiles.test.ts
new file mode 100644
index 0000000..1a32c56
--- /dev/null
+++ b/src/features/assistant/stream/downloadFiles.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import {
+ downloadFilesFromArtifacts,
+ mergeDownloadFiles,
+ seedFileOutputs,
+} from './downloadFiles'
+
+describe('downloadFiles helpers', () => {
+ it('extracts file artifacts', () => {
+ const files = downloadFilesFromArtifacts([
+ {
+ id: '1',
+ type: 'file',
+ name: 'best.xlsx',
+ content: { url: '/files/abc_best.xlsx', name: 'best.xlsx' },
+ },
+ { id: '2', type: 'image', name: 'chart', content: '/images/x.png' },
+ ])
+ expect(files).toEqual([{ url: '/files/abc_best.xlsx', name: 'best.xlsx' }])
+ })
+
+ it('seeds execution outputs for restore', () => {
+ const outputs = seedFileOutputs([
+ {
+ id: '1',
+ type: 'file',
+ name: 'best.xlsx',
+ content: { url: '/files/abc_best.xlsx', name: 'best.xlsx' },
+ },
+ ])
+ expect(outputs.__exports).toHaveLength(1)
+ expect(outputs.__exports[0].output_type).toBe('file')
+ })
+
+ it('merges unique download lists', () => {
+ const merged = mergeDownloadFiles(
+ [{ url: '/files/a.xlsx', name: 'a.xlsx' }],
+ [{ url: '/files/a.xlsx', name: 'a.xlsx' }, { url: '/files/b.xlsx', name: 'b.xlsx' }],
+ )
+ expect(merged).toHaveLength(2)
+ })
+})
diff --git a/src/features/assistant/stream/downloadFiles.ts b/src/features/assistant/stream/downloadFiles.ts
new file mode 100644
index 0000000..9dcd8d9
--- /dev/null
+++ b/src/features/assistant/stream/downloadFiles.ts
@@ -0,0 +1,69 @@
+import type { KamidsoArtifact } from '../../../contracts/run'
+import type { DownloadableFile } from '../artifacts/FileDownloadCard'
+import { parseDownloadableFile } from '../artifacts/FileDownloadCard'
+import type { ExecutionOutput } from '../state/runState'
+
+/** Collect downloadable file outputs from a turn's execution steps. */
+export function downloadFilesFromExecution(
+ execution?: { outputs?: Record } | null,
+): DownloadableFile[] {
+ if (!execution?.outputs) return []
+ const seen = new Set()
+ const files: DownloadableFile[] = []
+ for (const stepOutputs of Object.values(execution.outputs)) {
+ for (const output of stepOutputs) {
+ if (output.output_type !== 'file') continue
+ const parsed = parseDownloadableFile(output.content)
+ if (!parsed || seen.has(parsed.url)) continue
+ seen.add(parsed.url)
+ files.push(parsed)
+ }
+ }
+ return files
+}
+
+/** Extract Excel/CSV downloads from persisted run artifacts. */
+export function downloadFilesFromArtifacts(
+ artifacts: readonly KamidsoArtifact[] | undefined,
+): DownloadableFile[] {
+ if (!artifacts?.length) return []
+ const seen = new Set()
+ const files: DownloadableFile[] = []
+ for (const artifact of artifacts) {
+ if (artifact.type !== 'file') continue
+ const parsed = parseDownloadableFile(artifact.content)
+ if (!parsed || seen.has(parsed.url)) continue
+ seen.add(parsed.url)
+ files.push(parsed)
+ }
+ return files
+}
+
+export function mergeDownloadFiles(
+ ...lists: Array
+): DownloadableFile[] {
+ const seen = new Set()
+ const out: DownloadableFile[] = []
+ for (const list of lists) {
+ for (const file of list ?? []) {
+ if (!file?.url || seen.has(file.url)) continue
+ seen.add(file.url)
+ out.push(file)
+ }
+ }
+ return out
+}
+
+/** Seed a synthetic step output list so SessionTurn can render downloads after hydrate. */
+export function seedFileOutputs(
+ artifacts: readonly KamidsoArtifact[] | undefined,
+): Record {
+ const files = downloadFilesFromArtifacts(artifacts)
+ if (!files.length) return {}
+ return {
+ __exports: files.map((file) => ({
+ output_type: 'file',
+ content: { url: file.url, name: file.name },
+ })),
+ }
+}
diff --git a/src/features/assistant/stream/eventReducer.test.ts b/src/features/assistant/stream/eventReducer.test.ts
new file mode 100644
index 0000000..944f797
--- /dev/null
+++ b/src/features/assistant/stream/eventReducer.test.ts
@@ -0,0 +1,222 @@
+import { describe, expect, it } from 'vitest'
+import { initialRunState } from '../state/runState'
+import { runReducer } from './eventReducer'
+
+describe('runReducer', () => {
+ it('handles plan.updated canonical event', () => {
+ const state = initialRunState()
+ const next = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: {
+ type: 'plan.updated',
+ tasks: [{ content: 'بررسی فایل', status: 'in_progress' }],
+ },
+ })
+ expect(next.plan).toHaveLength(1)
+ expect(next.plan[0]?.content).toBe('بررسی فایل')
+ })
+
+ it('handles step.started canonical event', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'RUN_CREATED',
+ runId: 'run_1',
+ viewId: 'v1',
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'step.started', id: 's1', title: 'تحلیل داده', step_type: 'code_execution' },
+ })
+ expect(state.executionByViewId.v1?.steps).toHaveLength(1)
+ expect(state.executionByViewId.v1?.steps[0]?.title).toBe('تحلیل داده')
+ })
+
+ it('collapses hydrated steps that share an id', () => {
+ const state = runReducer(initialRunState(), {
+ type: 'HYDRATE_RUN',
+ run: {
+ id: 'run_1',
+ steps: [
+ { id: 'step-3', title: 'اجرای ابزار', status: 'running' },
+ { id: 'step-3', title: 'تحلیل داده', status: 'running' },
+ ],
+ executionByViewId: {
+ v1: {
+ steps: [
+ { id: 'step-3', title: 'اجرای ابزار', status: 'running' },
+ { id: 'step-3', title: 'تحلیل داده', status: 'running' },
+ ],
+ outputs: {},
+ activeStepId: 'step-3',
+ stepThoughts: {},
+ },
+ },
+ },
+ })
+ expect(state.steps).toHaveLength(1)
+ expect(state.steps[0]?.title).toBe('تحلیل داده')
+ expect(state.executionByViewId.v1?.steps).toHaveLength(1)
+ })
+
+ it('handles question.requested', () => {
+ const next = runReducer(initialRunState(), {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'question.requested', question: 'کدام بازه؟', id: 'q1', run_id: 'run_1' },
+ })
+ expect(next.status).toBe('waiting_for_user')
+ expect(next.question?.question).toBe('کدام بازه؟')
+ })
+
+ it('clears pending question on question.replied', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'question.asked', question: 'کدام دیتابیس؟', id: 'q1', run_id: 'run_1' },
+ })
+ expect(state.question?.id).toBe('q1')
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'question.replied', id: 'q1', run_id: 'run_1' },
+ })
+ expect(state.question).toBeNull()
+ expect(state.status).toBe('running')
+ })
+
+ it('clears pending question on terminal final even without reply event', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'question.asked', question: 'کدام دیتابیس؟', id: 'q1', run_id: 'run_1' },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'answer.completed', content: 'تمام' },
+ })
+ expect(state.question).toBeNull()
+ expect(state.status).toBe('completed')
+ })
+
+ it('handles answer.completed as final', () => {
+ const next = runReducer(initialRunState(), {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'answer.completed', content: 'نتیجه نهایی' },
+ })
+ expect(next.answer).toBe('نتیجه نهایی')
+ expect(next.status).toBe('completed')
+ expect(next.loading).toBe(false)
+ })
+
+ it('streams thinking.delta into per-view highlight text', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'RUN_CREATED',
+ runId: 'run_1',
+ viewId: 'v1',
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.started', id: 's1', round: 1 },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.delta', id: 's1', content: 'حالا ' },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.delta', id: 's1', content: 'شواهد کافی دارم.' },
+ })
+ expect(state.thinkingByViewId.v1).toBe('حالا شواهد کافی دارم.')
+ expect(state.executionByViewId.v1?.stepThoughts.s1).toBe('حالا شواهد کافی دارم.')
+ })
+
+ it('replaces thinking highlight when delta.replace is set', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'RUN_CREATED',
+ runId: 'run_1',
+ viewId: 'v1',
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.delta', id: 's1', content: 'Thought' },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: {
+ type: 'thinking.delta',
+ id: 's1',
+ content: 'the chart series needs field',
+ replace: true,
+ },
+ })
+ expect(state.thinkingByViewId.v1).toBe('the chart series needs field')
+ expect(state.executionByViewId.v1?.stepThoughts.s1).toBe('the chart series needs field')
+ })
+
+ it('ignores ping frames and already-applied sequences', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.delta', sequence: 4, content: 'اول' },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'ping' },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.delta', sequence: 4, content: 'اول' },
+ })
+ expect(state.thinkingByViewId.v1).toBe('اول')
+ expect(state.lastEventSequence).toBe(4)
+ })
+
+ it('keeps thinking text when a run is cancelled', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'RUN_CREATED',
+ runId: 'run_1',
+ viewId: 'v1',
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'thinking.delta', content: 'در حال ساخت جدول' },
+ })
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: { type: 'run.cancelled' },
+ })
+ expect(state.status).toBe('cancelled')
+ expect(state.loading).toBe(false)
+ expect(state.thinkingByViewId.v1).toBe('در حال ساخت جدول')
+ })
+
+ it('stores html artifacts for the chat iframe', () => {
+ let state = runReducer(initialRunState(), {
+ type: 'RUN_CREATED',
+ runId: 'run_1',
+ viewId: 'v1',
+ })
+ const html = "گزارش
"
+ state = runReducer(state, {
+ type: 'PROCESS_EVENT',
+ viewId: 'v1',
+ payload: {
+ type: 'artifact.created',
+ artifact: { id: 'a1', type: 'html', name: 'گزارش', content: html },
+ },
+ })
+ expect(state.htmlByViewId.v1).toBe(html)
+ })
+})
diff --git a/src/features/assistant/stream/eventReducer.ts b/src/features/assistant/stream/eventReducer.ts
new file mode 100644
index 0000000..b69e229
--- /dev/null
+++ b/src/features/assistant/stream/eventReducer.ts
@@ -0,0 +1,385 @@
+import type { KamidsoArtifact, PlanTask } from '../../../contracts/run'
+import { emptyExecution, initialRunState, type ExecutionState, type RunState } from '../state/runState'
+import { normalizeKamidsoEvent } from './normalizeEvent'
+
+export type RunAction =
+ | { type: 'RESET' }
+ | { type: 'RUN_CREATED'; runId: string; viewId: string }
+ | { type: 'SET_LOADING'; loading: boolean }
+ | { type: 'SET_ACTIVE_VIEW'; viewId: string | null }
+ | { type: 'PROCESS_EVENT'; viewId: string; payload: Record }
+ | { type: 'HYDRATE_RUN'; run: Partial }
+ | { type: 'CLEAR_QUESTION' }
+
+export function runReducer(state: RunState, action: RunAction): RunState {
+ switch (action.type) {
+ case 'RESET':
+ return initialRunState()
+ case 'RUN_CREATED':
+ return {
+ ...state,
+ id: action.runId,
+ status: 'running',
+ loading: true,
+ activeViewId: action.viewId,
+ thinkingByViewId: {
+ ...state.thinkingByViewId,
+ [action.viewId]: '',
+ },
+ executionByViewId: {
+ ...state.executionByViewId,
+ [action.viewId]: emptyExecution(),
+ },
+ }
+ case 'SET_LOADING':
+ return { ...state, loading: action.loading }
+ case 'SET_ACTIVE_VIEW':
+ return { ...state, activeViewId: action.viewId }
+ case 'HYDRATE_RUN': {
+ const hydrated = { ...action.run, ...dedupedHydration(action.run) }
+ const patch: Partial = {}
+ for (const [key, value] of Object.entries(hydrated)) {
+ if (value !== undefined) {
+ ;(patch as Record)[key] = value
+ }
+ }
+ return {
+ ...state,
+ ...patch,
+ executionByViewId: {
+ ...state.executionByViewId,
+ ...(patch.executionByViewId ?? {}),
+ },
+ artifacts: patch.artifacts ?? state.artifacts,
+ htmlByViewId: {
+ ...state.htmlByViewId,
+ ...(patch.htmlByViewId ?? {}),
+ },
+ thinkingByViewId: {
+ ...state.thinkingByViewId,
+ ...(patch.thinkingByViewId ?? {}),
+ },
+ }
+ }
+ case 'CLEAR_QUESTION':
+ return { ...state, question: null, status: state.status === 'waiting_for_user' ? 'running' : state.status }
+ case 'PROCESS_EVENT': {
+ if (String(action.payload.type ?? '') === 'ping') return state
+ const event = normalizeKamidsoEvent(action.payload)
+ const seq = Number(event.sequence ?? 0)
+ if (seq > 0 && seq <= state.lastEventSequence) return state
+ const nextSeq = seq > state.lastEventSequence ? seq : state.lastEventSequence
+ return applyEvent(state, action.viewId, event, nextSeq)
+ }
+ default:
+ return state
+ }
+}
+
+function applyEvent(
+ state: RunState,
+ viewId: string,
+ event: Record,
+ lastEventSequence: number,
+): RunState {
+ const type = String(event.type ?? '')
+ let next: RunState = { ...state, lastEventSequence }
+
+ if (type === 'run.created' || type === 'run.started') {
+ return {
+ ...next,
+ id: String(event.run_id ?? state.id ?? ''),
+ status: 'running',
+ }
+ }
+ if (type === 'run.preparing') {
+ return { ...next, status: 'preparing' }
+ }
+ if (type === 'question.asked') {
+ const questions = event.questions as Array> | undefined
+ const first = Array.isArray(questions) ? questions[0] : undefined
+ return {
+ ...next,
+ status: 'waiting_for_user',
+ question: {
+ id: String(event.id ?? event.question_id ?? event.request_id ?? ''),
+ runId: String(event.run_id ?? state.id ?? ''),
+ question: String(
+ event.question ?? event.content ?? first?.question ?? first?.header ?? '',
+ ),
+ options: Array.isArray(event.options)
+ ? (event.options as unknown[]).map((o) => {
+ const opt = o as Record
+ return { id: String(opt.id ?? opt.value ?? ''), label: String(opt.label ?? opt.text ?? '') }
+ })
+ : Array.isArray(first?.options)
+ ? (first.options as unknown[]).map((o) => {
+ const opt = o as Record
+ return { id: String(opt.id ?? opt.value ?? ''), label: String(opt.label ?? opt.text ?? '') }
+ })
+ : undefined,
+ },
+ }
+ }
+ if (type === 'question.replied' || type === 'question.rejected' || type === 'question.resolved') {
+ return { ...next, question: null, status: 'running' }
+ }
+ if (type === 'plan.update' && Array.isArray(event.tasks)) {
+ return { ...next, plan: event.tasks as PlanTask[] }
+ }
+ if (type === 'artifact.created') {
+ const artifact = event.artifact as KamidsoArtifact | undefined
+ if (artifact) {
+ next = { ...next, artifacts: [...next.artifacts, artifact] }
+ if (artifact.type === 'html' && typeof artifact.content === 'string' && artifact.content.trim()) {
+ next = {
+ ...next,
+ htmlByViewId: { ...next.htmlByViewId, [viewId]: artifact.content },
+ }
+ }
+ // Keep file exports on the turn execution so chat download cards survive
+ // active-view switches and don't depend only on the ephemeral panel.
+ if (artifact.type === 'file') {
+ const exec = next.executionByViewId[viewId] ?? emptyExecution()
+ const list = [...(exec.outputs.__exports || [])]
+ list.push({ output_type: 'file', content: artifact.content })
+ next = {
+ ...next,
+ executionByViewId: {
+ ...next.executionByViewId,
+ [viewId]: {
+ ...exec,
+ outputs: { ...exec.outputs, __exports: list },
+ },
+ },
+ }
+ }
+ return next
+ }
+ }
+ if (type === 'answer.delta') {
+ const delta = String(event.content ?? '')
+ return { ...next, answer: next.answer + delta }
+ }
+ if (type === 'thinking.started') {
+ return {
+ ...next,
+ thinkingByViewId: { ...next.thinkingByViewId, [viewId]: '' },
+ }
+ }
+ if (type === 'thinking.delta') {
+ const delta = String(event.content ?? '')
+ const replace = Boolean(event.replace)
+ if (!delta && !replace) return next
+ const prev = next.thinkingByViewId[viewId] ?? ''
+ const nextText = replace ? delta : prev + delta
+ const stepId = event.id ? String(event.id) : null
+ let exec = next.executionByViewId[viewId]
+ if (stepId) {
+ exec = exec ?? emptyExecution()
+ exec = {
+ ...exec,
+ stepThoughts: {
+ ...exec.stepThoughts,
+ [stepId]: nextText,
+ },
+ }
+ next = {
+ ...next,
+ executionByViewId: { ...next.executionByViewId, [viewId]: exec },
+ }
+ }
+ return {
+ ...next,
+ thinkingByViewId: { ...next.thinkingByViewId, [viewId]: nextText },
+ }
+ }
+ if (type === 'final') {
+ const content = String(event.content ?? event.answer ?? next.answer)
+ return {
+ ...next,
+ answer: content,
+ status: 'completed',
+ loading: false,
+ question: null,
+ plan: [],
+ thinkingByViewId: { ...next.thinkingByViewId, [viewId]: '' },
+ executionByViewId: finalizeExecution(next.executionByViewId, viewId),
+ }
+ }
+ if (type === 'run.failed') {
+ const err = (event.error as Record | undefined) ?? event
+ return {
+ ...next,
+ status: 'failed',
+ loading: false,
+ question: null,
+ error: {
+ code: String(err.code ?? 'RUN_FAILED'),
+ message: String(err.message ?? 'اجرای تحلیل ناموفق بود.'),
+ retryable: Boolean(err.retryable),
+ },
+ }
+ }
+ if (type === 'run.cancelled') {
+ return { ...next, status: 'cancelled', loading: false, question: null }
+ }
+ if (type === 'done' || type === 'run.completed') {
+ return { ...next, status: 'completed', loading: false, plan: [], question: null }
+ }
+
+ const exec = next.executionByViewId[viewId] ?? emptyExecution()
+ const updatedExec = applyExecutionEvent(exec, event)
+ if (updatedExec !== exec) {
+ next = {
+ ...next,
+ executionByViewId: { ...next.executionByViewId, [viewId]: updatedExec },
+ steps: updatedExec.steps,
+ }
+ if (type === 'step.chunk') {
+ const outputType = String(event.output_type ?? '')
+ if (outputType === 'html' && typeof event.content === 'string' && event.content.trim()) {
+ next = {
+ ...next,
+ htmlByViewId: {
+ ...next.htmlByViewId,
+ [viewId]: event.content,
+ },
+ }
+ }
+ }
+ }
+ return next
+}
+
+function dedupeSteps(steps: T[]): T[] {
+ const order: string[] = []
+ const latest = new Map()
+ for (const step of steps) {
+ if (!latest.has(step.id)) order.push(step.id)
+ latest.set(step.id, step)
+ }
+ return order.map((id) => latest.get(id) as T)
+}
+
+function dedupedHydration(run: Partial): Partial {
+ const next: Partial = {}
+ if (run.steps) next.steps = dedupeSteps(run.steps)
+ if (run.executionByViewId) {
+ next.executionByViewId = Object.fromEntries(
+ Object.entries(run.executionByViewId).map(([viewId, exec]) => [
+ viewId,
+ { ...exec, steps: dedupeSteps(exec.steps) },
+ ]),
+ )
+ }
+ return next
+}
+
+function finalizeExecution(
+ map: Record,
+ viewId: string,
+): Record {
+ const current = map[viewId]
+ if (!current) return map
+ return {
+ ...map,
+ [viewId]: {
+ ...current,
+ steps: current.steps.map((s) =>
+ s.status === 'running' ? { ...s, status: 'completed' as const } : s,
+ ),
+ },
+ }
+}
+
+function applyExecutionEvent(exec: ExecutionState, event: Record): ExecutionState {
+ const type = String(event.type ?? '')
+ if (type === 'step.start') {
+ const id = String(event.id ?? event.step ?? '')
+ const existingIndex = exec.steps.findIndex((s) => s.id === id)
+ let nextSteps
+ if (existingIndex >= 0) {
+ nextSteps = exec.steps.map((step, idx) =>
+ idx === existingIndex
+ ? {
+ ...step,
+ title: String(event.title ?? step.title),
+ detail: String(event.detail ?? step.detail ?? ''),
+ phase: String(event.phase ?? step.phase ?? ''),
+ status: 'running' as const,
+ }
+ : step.status === 'running'
+ ? { ...step, status: 'completed' as const }
+ : step,
+ )
+ } else {
+ nextSteps = [
+ ...exec.steps.map((item) =>
+ item.status === 'running' ? { ...item, status: 'completed' as const } : item,
+ ),
+ {
+ id,
+ step: Number(event.step ?? exec.steps.length + 1),
+ title: String(event.title ?? ''),
+ detail: String(event.detail ?? ''),
+ phase: String(event.phase ?? ''),
+ status: 'running' as const,
+ action: event.action ? String(event.action) : undefined,
+ },
+ ]
+ }
+ return {
+ ...exec,
+ steps: nextSteps,
+ outputs: { ...exec.outputs, [id]: exec.outputs[id] || [] },
+ activeStepId: existingIndex >= 0 ? id : exec.activeStepId || id,
+ }
+ }
+ if (type === 'step.meta') {
+ if (event.action && String(event.action).toLowerCase() === 'terminate') {
+ const id = String(event.id ?? '')
+ return {
+ ...exec,
+ steps: exec.steps.filter((s) => s.id !== id),
+ activeStepId: exec.activeStepId === id ? null : exec.activeStepId,
+ }
+ }
+ const thought = event.thought != null ? String(event.thought) : ''
+ const id = String(event.id ?? '')
+ return {
+ ...exec,
+ steps: exec.steps.map((item) =>
+ item.id === event.id
+ ? {
+ ...item,
+ title: String(event.title ?? item.title ?? ''),
+ action: event.action ? String(event.action) : item.action,
+ actionInput: event.action_input ?? item.actionInput,
+ }
+ : item,
+ ),
+ stepThoughts: thought && id ? { ...exec.stepThoughts, [id]: thought } : exec.stepThoughts,
+ activeStepId: event.action ? String(event.id) : exec.activeStepId,
+ }
+ }
+ if (type === 'step.chunk') {
+ const id = String(event.id ?? '')
+ const targetId = id || exec.activeStepId
+ if (!targetId) return exec
+ const list = [...(exec.outputs[targetId] || [])]
+ list.push({ output_type: String(event.output_type ?? 'text'), content: event.content })
+ return { ...exec, outputs: { ...exec.outputs, [targetId]: list } }
+ }
+ if (type === 'step.done') {
+ const id = String(event.id ?? '')
+ const targetId = id || exec.activeStepId
+ if (!targetId) return exec
+ const status = (event.status as ExecutionState['steps'][0]['status']) || 'completed'
+ return {
+ ...exec,
+ steps: exec.steps.map((item) => (item.id === targetId ? { ...item, status } : item)),
+ }
+ }
+ return exec
+}
diff --git a/src/features/assistant/stream/normalizeEvent.test.ts b/src/features/assistant/stream/normalizeEvent.test.ts
new file mode 100644
index 0000000..a8cb67a
--- /dev/null
+++ b/src/features/assistant/stream/normalizeEvent.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import { normalizeKamidsoEvent } from './normalizeEvent'
+
+describe('normalizeKamidsoEvent', () => {
+ it('maps step.output to step.chunk with full content', () => {
+ const event = normalizeKamidsoEvent({
+ type: 'step.output',
+ id: 'step-3',
+ output_type: 'code',
+ content: 'import pandas as pd\nprint(1)',
+ workspace: {
+ type: 'step.chunk',
+ id: 'step-3',
+ output_type: 'code',
+ content: 'import pandas as pd\nprint(1)',
+ },
+ })
+ expect(event.type).toBe('step.chunk')
+ expect(event.id).toBe('step-3')
+ expect(event.output_type).toBe('code')
+ expect(event.content).toBe('import pandas as pd\nprint(1)')
+ })
+
+ it('uses tool name from workspace on step.started', () => {
+ const event = normalizeKamidsoEvent({
+ type: 'step.started',
+ id: 'step-2',
+ step_type: 'tool_execution',
+ title: 'تحلیل داده',
+ action: 'code_interpreter',
+ workspace: {
+ type: 'step.start',
+ id: 'step-2',
+ title: 'code_interpreter',
+ detail: 'Thought/Action/Observation',
+ },
+ })
+ expect(event.type).toBe('step.start')
+ expect(event.title).toBe('تحلیل داده')
+ expect(event.action).toBe('code_interpreter')
+ })
+
+ it('prefers action_intention as the display title', () => {
+ const event = normalizeKamidsoEvent({
+ type: 'step.started',
+ id: 'step-2',
+ step_type: 'code_execution',
+ title: 'تحلیل داده',
+ action: 'code_interpreter',
+ action_intention: 'شمارش تیکتها',
+ workspace: {
+ type: 'step.start',
+ id: 'step-2',
+ title: 'code_interpreter',
+ action: 'code_interpreter',
+ },
+ })
+ expect(event.title).toBe('شمارش تیکتها')
+ expect(event.action).toBe('code_interpreter')
+ })
+})
diff --git a/src/features/assistant/stream/normalizeEvent.ts b/src/features/assistant/stream/normalizeEvent.ts
new file mode 100644
index 0000000..2d81142
--- /dev/null
+++ b/src/features/assistant/stream/normalizeEvent.ts
@@ -0,0 +1,89 @@
+/** Normalize backend dialects (canonical + legacy DB-GPT adapter) to internal event types. */
+export function normalizeKamidsoEvent(payload: Record): Record {
+ const type = String(payload.type ?? '')
+ if (type === 'question.requested' || type === 'question.asked') {
+ const questions = payload.questions as Array> | undefined
+ const first = Array.isArray(questions) ? questions[0] : undefined
+ const options = payload.options ?? first?.options
+ return {
+ ...payload,
+ type: 'question.asked',
+ id: payload.id ?? payload.question_id ?? payload.request_id,
+ question_id: payload.question_id ?? payload.request_id ?? payload.id,
+ question: payload.question ?? payload.content ?? first?.question ?? first?.header,
+ options: Array.isArray(options) ? options : undefined,
+ }
+ }
+ if (type === 'step.started') {
+ const ws = (payload.workspace ?? {}) as Record
+ const toolName = String(
+ payload.action ?? ws.action ?? ws.title ?? payload.step_type ?? '',
+ )
+ const intention = String(payload.action_intention ?? ws.action_intention ?? '').trim()
+ return {
+ ...payload,
+ type: 'step.start',
+ id: payload.id ?? ws.id,
+ title: String(intention || payload.title || toolName || ''),
+ detail: String(ws.detail ?? payload.input_summary ?? ''),
+ action: toolName || String(payload.step_type ?? ''),
+ }
+ }
+ if (type === 'step.meta') {
+ const ws = (payload.workspace ?? {}) as Record
+ const intention = String(payload.action_intention ?? ws.action_intention ?? '').trim()
+ return {
+ ...payload,
+ type: 'step.meta',
+ id: payload.id ?? ws.id,
+ title: intention || payload.title || ws.title,
+ action: payload.action ?? ws.action,
+ action_input: payload.action_input ?? ws.action_input,
+ thought: payload.thought ?? ws.thought,
+ }
+ }
+ if (type === 'step.updated') {
+ return {
+ ...payload,
+ type: 'step.meta',
+ content: payload.output_summary ?? payload.content,
+ action: payload.step_type,
+ }
+ }
+ if (type === 'step.output' || type === 'step.chunk') {
+ const ws = (payload.workspace ?? {}) as Record
+ return {
+ ...payload,
+ type: 'step.chunk',
+ id: payload.id ?? ws.id,
+ content: payload.content ?? ws.content ?? payload.output_summary,
+ output_type: payload.output_type ?? ws.output_type ?? 'text',
+ }
+ }
+ if (type === 'step.completed' || type === 'step.failed') {
+ return {
+ ...payload,
+ type: 'step.done',
+ status: type === 'step.failed' ? 'failed' : 'done',
+ }
+ }
+ if (type === 'answer.delta') {
+ return { ...payload, type: 'answer.delta', content: payload.content }
+ }
+ if (type === 'thinking.started' || type === 'thinking.delta') {
+ return {
+ ...payload,
+ type,
+ id: payload.id,
+ content: payload.content,
+ replace: payload.replace,
+ }
+ }
+ if (type === 'answer.completed' || type === 'run.completed') {
+ return { ...payload, type: 'final', content: payload.content ?? payload.answer }
+ }
+ if (type === 'plan.updated') {
+ return { ...payload, type: 'plan.update', tasks: payload.tasks ?? payload.plan }
+ }
+ return payload
+}
diff --git a/src/features/assistant/stream/reconnect.ts b/src/features/assistant/stream/reconnect.ts
new file mode 100644
index 0000000..ea29bbe
--- /dev/null
+++ b/src/features/assistant/stream/reconnect.ts
@@ -0,0 +1,5 @@
+export type ReconnectState = 'idle' | 'reconnecting' | 'connected' | 'failed'
+
+export function backoffMs(attempt: number, base = 1000, max = 15000): number {
+ return Math.min(base * 2 ** attempt, max)
+}
diff --git a/src/features/assistant/stream/useRunStream.ts b/src/features/assistant/stream/useRunStream.ts
new file mode 100644
index 0000000..41cdb0f
--- /dev/null
+++ b/src/features/assistant/stream/useRunStream.ts
@@ -0,0 +1,652 @@
+import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
+import { streamChat, cancelRun } from '../api/chat.api'
+import { getConversationHistory } from '../api/conversations.api'
+import { getRun, streamRunEvents } from '../api/runs.api'
+import { isLiveRunStatus } from '../../../contracts/common'
+import type { KamidsoArtifact } from '../../../contracts/run'
+import { unwrapWorkspaceEvent } from '../lib/events'
+import { cleanFinalContent, decodeFinalEvent } from '../lib/final'
+import type { ChatMessage, ExecutionStep } from '../types/chat'
+import { initialRunState } from '../state/runState'
+import { historyToMessages } from '../adapters/historyToMessages'
+import { runReducer } from './eventReducer'
+import { seedFileOutputs, downloadFilesFromArtifacts } from './downloadFiles'
+
+const ACTIVE_RUN_KEY = 'kamidso.activeRunByConv'
+const STREAM_TERMINAL_TYPES = new Set([
+ 'final',
+ 'answer.completed',
+ 'run.completed',
+ 'run.failed',
+ 'run.cancelled',
+ 'done',
+])
+
+function sleep(ms: number) {
+ return new Promise((resolve) => window.setTimeout(resolve, ms))
+}
+
+function readActiveRunMap(): Record {
+ try {
+ const raw = sessionStorage.getItem(ACTIVE_RUN_KEY)
+ if (!raw) return {}
+ const parsed = JSON.parse(raw) as Record
+ return parsed && typeof parsed === 'object' ? parsed : {}
+ } catch {
+ return {}
+ }
+}
+
+function rememberActiveRun(conversationId: string, runId: string, viewId: string) {
+ if (!conversationId || !runId) return
+ try {
+ const map = readActiveRunMap()
+ map[conversationId] = { runId, viewId }
+ sessionStorage.setItem(ACTIVE_RUN_KEY, JSON.stringify(map))
+ } catch {
+ /* ignore quota / private mode */
+ }
+}
+
+function forgetActiveRun(conversationId: string | null) {
+ if (!conversationId) return
+ try {
+ const map = readActiveRunMap()
+ delete map[conversationId]
+ sessionStorage.setItem(ACTIVE_RUN_KEY, JSON.stringify(map))
+ } catch {
+ /* ignore */
+ }
+}
+
+function peekActiveRun(conversationId: string) {
+ return readActiveRunMap()[conversationId] ?? null
+}
+
+type StreamSendParams = {
+ conversationId: string
+ query: string
+ model?: string
+ skillId?: string
+ reasoningMode?: 'fast' | 'medium' | 'deep'
+ reportMode?: boolean
+ resources?: Array<{ id: string; type: 'file' | 'datasource' | 'knowledge'; name?: string }>
+ humanMsg: ChatMessage
+ viewMsg: ChatMessage
+}
+
+export function useRunStream() {
+ const [state, dispatch] = useReducer(runReducer, undefined, initialRunState)
+ const [messages, setMessages] = useState([])
+ const [conversationId, setConversationId] = useState(null)
+ const abortRef = useRef(null)
+ const activeRunId = useRef(null)
+ const conversationIdRef = useRef(null)
+ const messagesRef = useRef([])
+ const thinkingByViewIdRef = useRef>({})
+ const activeViewIdRef = useRef(null)
+ const lastSendRef = useRef(null)
+ const restoreGen = useRef(0)
+ const streamGen = useRef(0)
+ const lastSeqRef = useRef(0)
+
+ messagesRef.current = messages
+ thinkingByViewIdRef.current = state.thinkingByViewId
+ activeViewIdRef.current = state.activeViewId
+
+ const bindConversation = useCallback((id: string | null) => {
+ conversationIdRef.current = id
+ setConversationId(id)
+ }, [])
+
+ const stop = useCallback(() => {
+ streamGen.current += 1
+ abortRef.current?.abort()
+ abortRef.current = null
+ }, [])
+
+ useEffect(() => {
+ return () => {
+ abortRef.current?.abort()
+ abortRef.current = null
+ }
+ }, [])
+
+ const releaseLiveRun = useCallback(() => {
+ activeRunId.current = null
+ forgetActiveRun(conversationIdRef.current)
+ }, [])
+
+ const markViewStopped = useCallback((viewId: string | null) => {
+ if (!viewId) return
+ const thought = thinkingByViewIdRef.current[viewId] || ''
+ dispatch({
+ type: 'PROCESS_EVENT',
+ viewId,
+ payload: { type: 'run.cancelled' },
+ })
+ setMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === viewId && msg.role === 'view'
+ ? {
+ ...msg,
+ thinking: false,
+ stopped: true,
+ canRetry: true,
+ thought: thought || msg.thought,
+ context: '',
+ }
+ : msg,
+ ),
+ )
+ }, [])
+
+ const cancel = useCallback(async () => {
+ const runId = activeRunId.current
+ const viewId = activeViewIdRef.current
+ if (runId) {
+ await cancelRun(runId)
+ }
+ markViewStopped(viewId)
+ releaseLiveRun()
+ stop()
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }, [markViewStopped, releaseLiveRun, stop])
+
+ const readStream = useCallback(async (response: Response, viewId: string) => {
+ const reader = response.body!.getReader()
+ const decoder = new TextDecoder('utf-8')
+ const thisController = abortRef.current
+ let buffer = ''
+ let reachedTerminal = false
+ try {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+ const parts = buffer.split('\n\n')
+ buffer = parts.pop() || ''
+ for (const part of parts) {
+ const line = part.trim()
+ if (!line.startsWith('data:')) continue
+ const data = line.slice(5).trim()
+ if (!data || data === '[DONE]') continue
+ try {
+ const payload = unwrapWorkspaceEvent(JSON.parse(data) as Record)
+ if (payload.type === 'ping') continue
+ const seq = Number(payload.sequence ?? 0)
+ if (seq > lastSeqRef.current) lastSeqRef.current = seq
+ if (STREAM_TERMINAL_TYPES.has(String(payload.type ?? ''))) {
+ reachedTerminal = true
+ activeRunId.current = null
+ forgetActiveRun(conversationIdRef.current)
+ }
+ if (payload.run_id && !STREAM_TERMINAL_TYPES.has(String(payload.type ?? ''))) {
+ activeRunId.current = String(payload.run_id)
+ rememberActiveRun(conversationIdRef.current || '', String(payload.run_id), viewId)
+ setMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === viewId && msg.role === 'view'
+ ? { ...msg, runId: String(payload.run_id) }
+ : msg,
+ ),
+ )
+ }
+ dispatch({ type: 'PROCESS_EVENT', viewId, payload })
+
+ if (payload.type === 'artifact.created') {
+ const artifact = payload.artifact as KamidsoArtifact | undefined
+ if (artifact?.type === 'file') {
+ const files = downloadFilesFromArtifacts([artifact])
+ if (files.length) {
+ setMessages((prev) =>
+ prev.map((msg) => {
+ if (msg.id !== viewId || msg.role !== 'view') return msg
+ const existing = msg.downloadFiles ?? []
+ const seen = new Set(existing.map((f) => f.url))
+ const merged = [...existing]
+ for (const file of files) {
+ if (!seen.has(file.url)) merged.push(file)
+ }
+ return { ...msg, downloadFiles: merged }
+ }),
+ )
+ }
+ }
+ }
+
+ if (payload.type === 'final' || payload.type === 'answer.completed') {
+ const finalAnswer = decodeFinalEvent(payload)
+ const summaryText = cleanFinalContent(finalAnswer.content)
+ setMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === viewId && msg.role === 'view'
+ ? { ...msg, context: summaryText, citations: finalAnswer.citations, thinking: false }
+ : msg,
+ ),
+ )
+ }
+ if (payload.type === 'plan.update' || payload.type === 'plan.updated') {
+ const tasks = (payload.tasks ?? payload.plan) as ChatMessage['taskPlan']
+ if (Array.isArray(tasks)) {
+ setMessages((prev) =>
+ prev.map((msg) => (msg.id === viewId && msg.role === 'view' ? { ...msg, taskPlan: tasks } : msg)),
+ )
+ }
+ }
+ if (payload.type === 'run.cancelled') {
+ markViewStopped(viewId)
+ }
+ if (payload.type === 'run.failed') {
+ const err = (payload.error as Record | undefined) ?? payload
+ const message = String(err.message ?? 'خطا در دریافت پاسخ. دوباره تلاش کنید.')
+ setMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === viewId && msg.role === 'view' ? { ...msg, context: message, thinking: false } : msg,
+ ),
+ )
+ }
+ } catch {
+ /* ignore malformed */
+ }
+ }
+ }
+ } finally {
+ if (abortRef.current === thisController) {
+ abortRef.current = null
+ }
+ }
+ return reachedTerminal
+ }, [markViewStopped])
+
+ const finishInactiveRun = useCallback((viewId: string, run: { status?: string; answer?: string | null; error?: unknown }) => {
+ if (run.status === 'cancelled') {
+ markViewStopped(viewId)
+ activeRunId.current = null
+ forgetActiveRun(conversationIdRef.current)
+ dispatch({ type: 'SET_LOADING', loading: false })
+ return
+ }
+ const failed = run.status === 'failed'
+ const message = failed
+ ? String((run.error as { message?: string } | undefined)?.message ?? 'خطا در دریافت پاسخ. دوباره تلاش کنید.')
+ : run.answer
+ ? String(run.answer)
+ : undefined
+ setMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === viewId && msg.role === 'view'
+ ? {
+ ...msg,
+ context: message ?? msg.context,
+ thinking: false,
+ }
+ : msg,
+ ),
+ )
+ activeRunId.current = null
+ forgetActiveRun(conversationIdRef.current)
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }, [markViewStopped])
+
+ const recoverLiveStream = useCallback(
+ async (viewId: string, gen: number) => {
+ const runId = activeRunId.current
+ if (!runId) {
+ dispatch({ type: 'SET_LOADING', loading: false })
+ return
+ }
+ let delay = 400
+ while (gen === streamGen.current) {
+ await sleep(delay)
+ if (gen !== streamGen.current) return
+ try {
+ const run = await getRun(runId)
+ if (gen !== streamGen.current) return
+ if (!isLiveRunStatus(run.status)) {
+ finishInactiveRun(viewId, run)
+ return
+ }
+ dispatch({ type: 'SET_LOADING', loading: true })
+ const controller = new AbortController()
+ abortRef.current = controller
+ const response = await streamRunEvents(runId, lastSeqRef.current, controller.signal)
+ const terminal = await readStream(response, viewId)
+ if (terminal || controller.signal.aborted || gen !== streamGen.current) return
+ delay = 400
+ } catch (err) {
+ if ((err as Error).name === 'AbortError' || gen !== streamGen.current) return
+ delay = Math.min(Math.round(delay * 1.5), 5000)
+ }
+ }
+ },
+ [finishInactiveRun, readStream],
+ )
+
+ const hydrateRun = useCallback(
+ async (runId: string, viewId: string, options?: { live?: boolean; setActive?: boolean }) => {
+ const setActive = options?.setActive !== false
+ try {
+ const run = await getRun(runId)
+ if (setActive) activeRunId.current = runId
+ const live = options?.live ?? isLiveRunStatus(run.status)
+ const steps = (run.steps ?? []).map((step, index) => ({
+ id: step.id,
+ step: step.step ?? index + 1,
+ title: step.title,
+ status: (step.status === 'done' ? 'done' : step.status) as ExecutionStep['status'],
+ detail: step.detail,
+ phase: step.phase,
+ action: step.action,
+ }))
+ const running = [...steps].reverse().find((item) => item.status === 'running')
+ const artifacts = (run.artifacts ?? []) as KamidsoArtifact[]
+ const seededOutputs = seedFileOutputs(artifacts)
+ const downloadFiles = downloadFilesFromArtifacts(artifacts)
+ dispatch({
+ type: 'HYDRATE_RUN',
+ run: {
+ id: setActive ? runId : undefined,
+ status: setActive ? run.status : undefined,
+ answer: setActive ? run.answer ?? '' : undefined,
+ plan: setActive ? [] : undefined,
+ steps: setActive ? steps : undefined,
+ artifacts: setActive ? artifacts : undefined,
+ activeViewId: setActive ? viewId : undefined,
+ loading: setActive ? live : undefined,
+ lastEventSequence: setActive ? 0 : undefined,
+ executionByViewId: {
+ [viewId]: {
+ steps,
+ outputs: seededOutputs,
+ activeStepId: running?.id ?? steps[steps.length - 1]?.id ?? null,
+ stepThoughts: {},
+ },
+ },
+ },
+ })
+ setMessages((prev) =>
+ prev.map((msg) => {
+ if (msg.id !== viewId || msg.role !== 'view') return msg
+ return {
+ ...msg,
+ runId,
+ downloadFiles: downloadFiles.length
+ ? downloadFiles
+ : msg.downloadFiles,
+ context:
+ setActive && run.answer
+ ? run.answer
+ : msg.context,
+ thinking: setActive ? (live && !run.answer ? true : false) : msg.thinking,
+ }
+ }),
+ )
+ if (setActive) {
+ lastSeqRef.current = 0
+ const gen = streamGen.current
+ if (live) {
+ rememberActiveRun(conversationIdRef.current || '', runId, viewId)
+ dispatch({ type: 'SET_LOADING', loading: true })
+ const controller = new AbortController()
+ abortRef.current = controller
+ try {
+ const response = await streamRunEvents(runId, 0, controller.signal)
+ const terminal = await readStream(response, viewId)
+ if (!terminal && !controller.signal.aborted && gen === streamGen.current) {
+ await recoverLiveStream(viewId, gen)
+ } else if (gen === streamGen.current) {
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }
+ } catch (err) {
+ if ((err as Error).name === 'AbortError' || gen !== streamGen.current) return
+ await recoverLiveStream(viewId, gen)
+ }
+ } else {
+ if (setActive) activeRunId.current = null
+ forgetActiveRun(conversationIdRef.current)
+ const controller = new AbortController()
+ abortRef.current = controller
+ try {
+ const response = await streamRunEvents(runId, 0, controller.signal)
+ await readStream(response, viewId)
+ } catch {
+ /* replay best-effort */
+ }
+ if (gen === streamGen.current) {
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }
+ }
+ } else if (!live && downloadFiles.length === 0) {
+ try {
+ const response = await streamRunEvents(runId, 0)
+ await readStream(response, viewId)
+ } catch {
+ /* keep seeded/empty state */
+ }
+ }
+ } catch (err) {
+ if (setActive && (err as Error).name !== 'AbortError') {
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }
+ }
+ },
+ [readStream, recoverLiveStream],
+ )
+
+ const restoreConversation = useCallback(
+ async (nextConversationId: string) => {
+ if (
+ conversationIdRef.current === nextConversationId &&
+ activeRunId.current &&
+ abortRef.current
+ ) {
+ bindConversation(nextConversationId)
+ return
+ }
+
+ const gen = ++restoreGen.current
+ stop()
+ bindConversation(nextConversationId)
+ dispatch({ type: 'RESET' })
+ setMessages([])
+ activeRunId.current = null
+
+ try {
+ const data = await getConversationHistory(nextConversationId)
+ if (gen !== restoreGen.current) return
+
+ const stored = peekActiveRun(nextConversationId)
+ const latestRun = data.latest_run
+ ? {
+ id: data.latest_run.id,
+ status: data.latest_run.status,
+ answer: data.latest_run.answer,
+ resources: data.latest_run.resources,
+ }
+ : stored
+ ? { id: stored.runId, status: 'running' }
+ : null
+ const mapped = historyToMessages(data.messages, latestRun)
+ setMessages(mapped)
+
+ const runId = data.active_run_id || stored?.runId || data.latest_run?.id
+ const lastView = [...mapped].reverse().find((msg) => msg.role === 'view')
+
+ // Restore download links for every prior turn that has a run_id.
+ const priorViews = mapped.filter(
+ (msg) => msg.role === 'view' && msg.runId && msg.id !== lastView?.id,
+ )
+ for (const view of priorViews) {
+ if (gen !== restoreGen.current || !view.runId) break
+ await hydrateRun(view.runId, view.id, { live: false, setActive: false })
+ }
+
+ if (runId && lastView) {
+ await hydrateRun(runId, lastView.id, {
+ live: isLiveRunStatus(latestRun?.status || data.latest_run?.status || ''),
+ setActive: true,
+ })
+ }
+ } catch {
+ if (gen === restoreGen.current) {
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }
+ }
+ },
+ [bindConversation, hydrateRun, stop],
+ )
+
+ const startStream = useCallback(
+ async (params: StreamSendParams) => {
+ lastSendRef.current = params
+ lastSeqRef.current = 0
+ const gen = streamGen.current
+ bindConversation(params.conversationId)
+ dispatch({ type: 'RUN_CREATED', runId: '', viewId: params.viewMsg.id })
+ dispatch({ type: 'SET_LOADING', loading: true })
+
+ const controller = new AbortController()
+ abortRef.current = controller
+
+ try {
+ const response = await streamChat(
+ {
+ conversation_id: params.conversationId,
+ message: params.query,
+ model_id: params.model,
+ skill_id: params.skillId,
+ reasoning_mode: params.reasoningMode || 'fast',
+ report_mode: Boolean(params.reportMode),
+ resources: params.resources,
+ mode: 'auto',
+ temperature: 0.2,
+ max_new_tokens: 65536,
+ },
+ controller.signal,
+ )
+ const terminal = await readStream(response, params.viewMsg.id)
+ if (!terminal && !controller.signal.aborted && gen === streamGen.current) {
+ await recoverLiveStream(params.viewMsg.id, gen)
+ } else if (gen === streamGen.current) {
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }
+ } catch (err) {
+ if ((err as Error).name === 'AbortError' || gen !== streamGen.current) return
+ if (activeRunId.current) {
+ await recoverLiveStream(params.viewMsg.id, gen)
+ return
+ }
+ setMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === params.viewMsg.id
+ ? { ...msg, context: 'خطا در دریافت پاسخ. دوباره تلاش کنید.', thinking: false }
+ : msg,
+ ),
+ )
+ dispatch({ type: 'SET_LOADING', loading: false })
+ }
+ },
+ [bindConversation, readStream, recoverLiveStream],
+ )
+
+ const send = useCallback(
+ async (params: StreamSendParams) => {
+ if (state.loading || abortRef.current) {
+ return
+ }
+ stop()
+ setMessages((prev) => [
+ ...prev.map((msg) =>
+ msg.role === 'view' && msg.stopped ? { ...msg, canRetry: false } : msg,
+ ),
+ params.humanMsg,
+ params.viewMsg,
+ ])
+ await startStream(params)
+ },
+ [startStream, state.loading, stop],
+ )
+
+ const retryStopped = useCallback(async () => {
+ if (state.loading || abortRef.current) return
+ stop()
+ const msgs = messagesRef.current
+ let viewIndex = -1
+ for (let i = msgs.length - 1; i >= 0; i -= 1) {
+ const msg = msgs[i]
+ if (msg.role === 'view' && msg.stopped && msg.canRetry) {
+ viewIndex = i
+ break
+ }
+ }
+ if (viewIndex < 1) return
+ const viewMsg = msgs[viewIndex]
+ const humanMsg = msgs[viewIndex - 1]
+ if (humanMsg.role !== 'human') return
+ const previous = lastSendRef.current
+ const conversationId = previous?.conversationId || conversationIdRef.current
+ if (!conversationId) return
+ const nextView: ChatMessage = {
+ ...viewMsg,
+ context: '',
+ thinking: true,
+ stopped: false,
+ canRetry: false,
+ thought: undefined,
+ }
+ setMessages((prev) => prev.map((msg) => (msg.id === viewMsg.id ? nextView : msg)))
+ await startStream({
+ conversationId,
+ query: previous?.query || humanMsg.context,
+ model: previous?.model,
+ skillId: previous?.skillId || humanMsg.attachedSkill?.id,
+ reasoningMode: previous?.reasoningMode,
+ reportMode: previous?.reportMode,
+ resources: previous?.resources,
+ humanMsg,
+ viewMsg: nextView,
+ })
+ }, [startStream, state.loading, stop])
+
+ const resetConversation = useCallback(() => {
+ restoreGen.current += 1
+ stop()
+ forgetActiveRun(conversationIdRef.current)
+ dispatch({ type: 'RESET' })
+ setMessages([])
+ activeRunId.current = null
+ bindConversation(null)
+ }, [bindConversation, stop])
+
+ const setPendingQuestion = useCallback((q: typeof state.question) => {
+ if (!q) dispatch({ type: 'CLEAR_QUESTION' })
+ }, [])
+
+ return {
+ messages,
+ setMessages,
+ conversationId,
+ executionMap: state.executionByViewId,
+ loading: state.loading,
+ taskPlan: state.plan,
+ pendingQuestion: state.question,
+ setPendingQuestion,
+ activeViewId: state.activeViewId,
+ setActiveViewId: (id: string | null) => dispatch({ type: 'SET_ACTIVE_VIEW', viewId: id }),
+ runStatus: state.status,
+ runId: state.id ?? activeRunId.current,
+ artifacts: state.artifacts,
+ htmlByViewId: state.htmlByViewId,
+ thinkingByViewId: state.thinkingByViewId,
+ answer: state.answer,
+ error: state.error,
+ send,
+ retryStopped,
+ stop,
+ cancel,
+ hydrateRun,
+ restoreConversation,
+ resetConversation,
+ }
+}
diff --git a/src/features/assistant/types/chat.ts b/src/features/assistant/types/chat.ts
new file mode 100644
index 0000000..ed26476
--- /dev/null
+++ b/src/features/assistant/types/chat.ts
@@ -0,0 +1,83 @@
+import type { SessionFileSnapshot } from '../session-files'
+import type { AgentCitation } from '../lib/final'
+
+export type TaskItem = {
+ content: string
+ status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
+ priority?: 'high' | 'medium' | 'low'
+}
+
+export type ExecutionStep = {
+ id: string
+ step?: number
+ title?: string
+ detail?: string
+ phase?: string
+ status: 'running' | 'done' | 'failed' | 'pending' | 'completed'
+ action?: string
+ actionInput?: unknown
+ todoMeta?: { state?: string; done?: number; total?: number }
+}
+
+export type ExecutionOutput = {
+ output_type: string
+ content: unknown
+}
+
+export type ExecutionState = {
+ steps: ExecutionStep[]
+ outputs: Record
+ activeStepId: string | null
+ stepThoughts: Record
+}
+
+export type ChatMessage = {
+ id: string
+ role: 'human' | 'view'
+ context: string
+ order?: number
+ thinking?: boolean
+ stopped?: boolean
+ canRetry?: boolean
+ thought?: string
+ runId?: string
+ /** Persisted downloadable exports for this turn (Excel/CSV). Survives refresh. */
+ downloadFiles?: Array<{ url: string; name: string }>
+ attachedFiles?: readonly SessionFileSnapshot[]
+ attachedDb?: { db_name: string; db_type: string }
+ attachedDatabases?: readonly { id?: string; db_name: string; db_type: string }[]
+ attachedSkill?: { name: string; id: string }
+ attachedKnowledge?: { name: string; id: string }
+ taskPlan?: TaskItem[]
+ citations?: AgentCitation[]
+}
+
+export type PendingQuestion = {
+ id: string
+ runId?: string
+ question: string
+ options?: Array<{ id: string; label: string }>
+}
+
+export type ArtifactItem = {
+ id: string
+ type: 'file' | 'table' | 'chart' | 'image' | 'code' | 'markdown' | 'html' | 'report'
+ name: string
+ content: unknown
+ filePath?: string
+}
+
+import type { SettingsSummary } from '../../settings/types'
+
+export type ResourceOption = {
+ id: string
+ name: string
+ description?: string
+ type?: string
+ nameEn?: string
+ category?: string
+ categoryId?: string
+ domain?: string
+ icon?: string
+ settings?: SettingsSummary
+}
diff --git a/src/features/assistant/types/viewModels.ts b/src/features/assistant/types/viewModels.ts
new file mode 100644
index 0000000..8de10dc
--- /dev/null
+++ b/src/features/assistant/types/viewModels.ts
@@ -0,0 +1,33 @@
+import type { KamidsoArtifact, PlanTask } from '../../../contracts/run'
+import type { WorkspaceFileSnapshot } from '../../../contracts/file'
+
+export type AgentTurnView = {
+ id: string
+ userMessage: string
+ assistantMessage?: string
+ taskPlan?: PlanTask[]
+ attachedFiles?: readonly WorkspaceFileSnapshot[]
+ isWorking?: boolean
+ thinkingContent?: string
+}
+
+export type ExecutionStepView = {
+ id: string
+ title: string
+ status: 'pending' | 'running' | 'completed' | 'failed'
+ detail?: string
+ toolLabel?: string
+}
+
+export type AttachmentView = {
+ id: string
+ name: string
+ sizeLabel: string
+ status: string
+ progress?: number
+ failed?: boolean
+}
+
+export type ArtifactView = KamidsoArtifact
+
+export type PlanView = PlanTask[]
diff --git a/src/features/assistant/workspace/ChatMessageList.test.tsx b/src/features/assistant/workspace/ChatMessageList.test.tsx
new file mode 100644
index 0000000..18e3371
--- /dev/null
+++ b/src/features/assistant/workspace/ChatMessageList.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { ChatMessageList } from './ChatMessageList'
+
+describe('ChatMessageList', () => {
+ it('shows the live work plan in the in-chat process card while loading', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('در حال اجرای برنامه کار')).toBeInTheDocument()
+ expect(screen.getByText('بررسی ساختار و واحد ارز فایل')).toBeInTheDocument()
+ expect(screen.getByText('محاسبه مقادیر مشاهدهشده ارزش افزوده با Python')).toBeInTheDocument()
+ expect(screen.getByText('تولید گزارش نتیجه با html_interpreter')).toBeInTheDocument()
+ expect(screen.getByText('1/3')).toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/workspace/ChatMessageList.tsx b/src/features/assistant/workspace/ChatMessageList.tsx
new file mode 100644
index 0000000..cc164c8
--- /dev/null
+++ b/src/features/assistant/workspace/ChatMessageList.tsx
@@ -0,0 +1,141 @@
+import { ArrowDown } from 'lucide-react'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { resolveTurnHtml } from '../report/extractReport'
+import type { ChatMessage, ExecutionState, TaskItem } from '../types/chat'
+import type { SessionFileSnapshot } from '../session-files/types'
+import { SessionTurn } from './SessionTurn'
+import { conversationWidthClass } from './conversationLayout'
+import {
+ downloadFilesFromExecution,
+ mergeDownloadFiles,
+} from '../stream/downloadFiles'
+
+export function ChatMessageList({
+ rounds,
+ loading,
+ executionMap,
+ activeViewId,
+ selectedStepId,
+ htmlByViewId,
+ thinkingByViewId,
+ liveTaskPlan,
+ hideTurnAttachments,
+ onOpenExecution,
+ onRetryStopped,
+ onPreviewFile,
+}: {
+ rounds: Array<{ human: ChatMessage; view?: ChatMessage }>
+ loading?: boolean
+ executionMap?: Record
+ activeViewId?: string | null
+ selectedStepId?: string | null
+ htmlByViewId?: Record
+ thinkingByViewId?: Record
+ liveTaskPlan?: TaskItem[]
+ hideTurnAttachments?: boolean
+ onOpenExecution?: (viewId: string, stepId?: string) => void
+ onRetryStopped?: () => void
+ onPreviewFile?: (file: SessionFileSnapshot) => void
+}) {
+ const containerRef = useRef(null)
+ const bottomRef = useRef(null)
+ const [showScrollButton, setShowScrollButton] = useState(false)
+
+ const scrollToBottom = useCallback(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
+ }, [])
+
+ useEffect(() => {
+ if (loading) scrollToBottom()
+ }, [loading, rounds.length, scrollToBottom, thinkingByViewId, activeViewId])
+
+ return (
+
+
{
+ const el = containerRef.current
+ if (!el) return
+ const distance = el.scrollHeight - el.scrollTop - el.clientHeight
+ setShowScrollButton(distance > 120)
+ }}
+ >
+
+ {rounds.map(({ human, view }, index) => {
+ const viewId = view?.id
+ const execution = viewId ? executionMap?.[viewId] : undefined
+ const isActiveTurn = Boolean(viewId && viewId === activeViewId)
+ const steps = execution?.steps ?? []
+ const activeStepId = isActiveTurn ? selectedStepId ?? execution?.activeStepId : undefined
+ const lastStepId = activeStepId ?? steps[steps.length - 1]?.id
+ const htmlReport = resolveTurnHtml({
+ viewId,
+ execution,
+ htmlByViewId,
+ })
+ const thinkingContent =
+ view?.thought?.trim() ||
+ (isActiveTurn ? thinkingByViewId?.[viewId ?? '']?.trim() || undefined : undefined)
+ const reportLoading = Boolean(
+ isActiveTurn && loading && !htmlReport && !view?.context?.trim() && !thinkingContent && !view?.stopped,
+ )
+ const isWorking = Boolean(
+ !view?.stopped && (view?.thinking || (isActiveTurn && loading && !view?.context?.trim())),
+ )
+ const canRetry = Boolean(view?.stopped && view?.canRetry && index === rounds.length - 1 && !loading)
+ const taskPlan =
+ isActiveTurn && liveTaskPlan?.length
+ ? liveTaskPlan
+ : view?.taskPlan?.length
+ ? view.taskPlan
+ : undefined
+ const downloadFiles = mergeDownloadFiles(
+ view?.downloadFiles,
+ downloadFilesFromExecution(execution),
+ )
+
+ return (
+
onOpenExecution(viewId, lastStepId)
+ : undefined
+ }
+ onRetry={canRetry ? onRetryStopped : undefined}
+ onPreviewFile={onPreviewFile}
+ />
+ )
+ })}
+
+
+
+ {showScrollButton ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/workspace/ChatWelcome.tsx b/src/features/assistant/workspace/ChatWelcome.tsx
new file mode 100644
index 0000000..19a1dbb
--- /dev/null
+++ b/src/features/assistant/workspace/ChatWelcome.tsx
@@ -0,0 +1,92 @@
+import { Database, FileSpreadsheet, Sparkles, Zap } from 'lucide-react'
+import { t } from '../../../i18n'
+
+const suggestions = [
+ {
+ icon: FileSpreadsheet,
+ title: 'تحلیل فایل',
+ description: 'Excel و CSV را بهعنوان فایل داده بارگذاری کنید و سؤال بپرسید',
+ preset: 'خلاصهای از دادههای فایل بده',
+ tone: 'bg-accent-soft text-accent',
+ },
+ {
+ icon: Database,
+ title: 'پرسوجوی داده',
+ description: 'به پایگاه داده وصل شوید و تحلیل کنید',
+ preset: 'ده مشتری برتر را نشان بده',
+ tone: 'bg-success-soft text-success',
+ },
+ {
+ icon: Sparkles,
+ title: 'دانش سازمان',
+ description: 'از اسناد و سیاستها پاسخ بگیرید',
+ tone: 'bg-warn-soft text-warn',
+ },
+ {
+ icon: Zap,
+ title: 'تحلیل سریع',
+ description: 'سؤال کسبوکار را مستقیم بپرسید',
+ tone: 'bg-info-soft text-info',
+ },
+]
+
+export function ChatWelcome({
+ onSuggestion,
+ className = '',
+}: {
+ onSuggestion?: (preset: string) => void
+ className?: string
+}) {
+ return (
+
+
+
+ K
+
+
+ کامیدسو · kamidso
+
+
+ {t('assistant.welcome.title')}
+
+
+ {t('assistant.welcome.subtitle')}
+
+
+ {suggestions.map((item, index) => {
+ const Icon = item.icon
+ return (
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/src/features/assistant/workspace/ConversationContextBar.test.tsx b/src/features/assistant/workspace/ConversationContextBar.test.tsx
new file mode 100644
index 0000000..0c57c60
--- /dev/null
+++ b/src/features/assistant/workspace/ConversationContextBar.test.tsx
@@ -0,0 +1,71 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { ConversationContextBar } from './ConversationContextBar'
+
+describe('ConversationContextBar', () => {
+ it('renders pinned files and database for the whole conversation', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/منابع این گفتگو/)).toBeInTheDocument()
+ expect(screen.getByText('Book1 (1).xlsx')).toBeInTheDocument()
+ expect(screen.getByText('sales-db')).toBeInTheDocument()
+ })
+
+ it('can remove a pinned file', () => {
+ const onRemoveFile = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'حذف' }))
+ expect(onRemoveFile).toHaveBeenCalledWith('file_book1')
+ })
+
+ it('collapses a long source list and expands on demand', () => {
+ const files = Array.from({ length: 8 }, (_, index) => ({
+ file_id: `file_${index}`,
+ name: `sheet-${index}.xlsx`,
+ size: 1024,
+ media_type: 'application/xlsx',
+ kind: 'excel' as const,
+ status: 'ready' as const,
+ ordinal: index,
+ }))
+ render()
+
+ expect(screen.getByText(/منابع این گفتگو/)).toBeInTheDocument()
+ expect(screen.queryByText('sheet-7.xlsx')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /بزرگکردن/ }))
+ expect(screen.getByText('sheet-7.xlsx')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /کوچککردن/ }))
+ expect(screen.queryByText('sheet-7.xlsx')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/workspace/ConversationContextBar.tsx b/src/features/assistant/workspace/ConversationContextBar.tsx
new file mode 100644
index 0000000..0d9e65b
--- /dev/null
+++ b/src/features/assistant/workspace/ConversationContextBar.tsx
@@ -0,0 +1,185 @@
+import { useState } from 'react'
+import { BookOpen, ChevronDown, Database, FileSpreadsheet, FileText, Image as ImageIcon, X } from 'lucide-react'
+import { t } from '../../../i18n'
+import type { SessionFileSnapshot } from '../session-files/types'
+import type { ChatMessage } from '../types/chat'
+
+const COLLAPSE_AFTER = 6
+
+function fileIcon(name: string) {
+ const ext = name.split('.').pop()?.toLowerCase() ?? ''
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return ImageIcon
+ if (['csv', 'xlsx', 'xls'].includes(ext)) return FileSpreadsheet
+ return FileText
+}
+
+function formatBytes(bytes: number) {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+function fileTone(name: string) {
+ const ext = name.split('.').pop()?.toLowerCase() ?? ''
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) {
+ return 'bg-violet-50 text-violet-600'
+ }
+ if (['csv', 'xlsx', 'xls'].includes(ext)) {
+ return 'bg-emerald-50 text-emerald-700'
+ }
+ return 'bg-slate-100 text-slate-600'
+}
+
+function PinChip({
+ icon: Icon,
+ label,
+ hint,
+ tone,
+ onRemove,
+ onClick,
+}: {
+ icon: typeof BookOpen
+ label: string
+ hint?: string
+ tone: string
+ onRemove?: () => void
+ onClick?: () => void
+}) {
+ const body = (
+ <>
+
+
+
+
+ {label}
+ {hint ? {hint} : null}
+
+ >
+ )
+
+ return (
+
+ {onClick ? (
+
+ ) : (
+ {body}
+ )}
+ {onRemove ? (
+
+ ) : null}
+
+ )
+}
+
+export function ConversationContextBar({
+ files,
+ knowledge,
+ databases = [],
+ onPreviewFile,
+ onRemoveFile,
+ onRemoveDatabase,
+ onClearKnowledge,
+}: {
+ files?: readonly SessionFileSnapshot[]
+ knowledge?: ChatMessage['attachedKnowledge']
+ databases?: readonly NonNullable[number][]
+ onPreviewFile?: (file: SessionFileSnapshot) => void
+ onRemoveFile?: (fileId: string) => void
+ onRemoveDatabase?: (key: string) => void
+ onClearKnowledge?: () => void
+}) {
+ const fileList = files ?? []
+ const hasFiles = fileList.length > 0
+ const hasKnowledge = Boolean(knowledge?.name)
+ const hasDb = databases.length > 0
+ const sourceCount = fileList.length + (hasKnowledge ? 1 : 0) + databases.length
+ const [expanded, setExpanded] = useState(sourceCount <= COLLAPSE_AFTER)
+ if (!hasFiles && !hasKnowledge && !hasDb) return null
+
+ const canToggle = sourceCount > 4
+ const showChips = expanded || !canToggle
+ const previewNames = [
+ ...(hasKnowledge && knowledge?.name ? [knowledge.name] : []),
+ ...databases.map((item) => item.db_name),
+ ...fileList.map((file) => file.name),
+ ]
+ const preview = previewNames.slice(0, 3).join('، ')
+ const extra = Math.max(0, sourceCount - 3)
+
+ return (
+
+
+
+ {t('assistant.chat.conversationSources')}
+ {sourceCount > 1 ? ` · ${sourceCount}` : ''}
+
+ {canToggle ? (
+
+ ) : (
+
{t('assistant.chat.conversationSourcesHint')}
+ )}
+
+ {showChips ? (
+
+ {hasKnowledge ? (
+
+ ) : null}
+ {databases.map((item) => (
+
onRemoveDatabase(item.id || item.db_name) : undefined
+ }
+ />
+ ))}
+ {fileList.map((file) => {
+ const Icon = fileIcon(file.name)
+ return (
+ 0 ? formatBytes(file.size) : t('assistant.composer.files')}
+ tone={fileTone(file.name)}
+ onClick={onPreviewFile ? () => onPreviewFile(file) : undefined}
+ onRemove={onRemoveFile ? () => onRemoveFile(file.file_id) : undefined}
+ />
+ )
+ })}
+
+ ) : (
+
+ {preview}
+ {extra > 0 ? ` +${extra}` : ''}
+
+ )}
+
+ )
+}
diff --git a/src/features/assistant/workspace/SessionTurn.test.tsx b/src/features/assistant/workspace/SessionTurn.test.tsx
new file mode 100644
index 0000000..ec969c2
--- /dev/null
+++ b/src/features/assistant/workspace/SessionTurn.test.tsx
@@ -0,0 +1,209 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { SessionTurn } from './SessionTurn'
+
+describe('SessionTurn', () => {
+ it('renders user bubble and assistant answer without execution timeline', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('سلام')).toBeInTheDocument()
+ expect(screen.getByText('پاسخ تست')).toBeInTheDocument()
+ expect(screen.queryByText('مرحله ۱')).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'مشاهده اجرا · 2' })).toBeInTheDocument()
+ })
+
+ it('shows a stopped thinking card with retry', () => {
+ const onRetry = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.getByText('متوقف شد')).toBeInTheDocument()
+ expect(screen.getByText(/در حال ساخت جدول کاربران/)).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /تلاش مجدد/ }))
+ expect(onRetry).toHaveBeenCalledOnce()
+ })
+
+ it('opens execution panel via thinking card callback', () => {
+ const onOpenExecution = vi.fn()
+ render(
+ ,
+ )
+
+ expect(screen.getByText('در حال فکر کردن…')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /در حال فکر کردن/ }))
+ expect(onOpenExecution).toHaveBeenCalledOnce()
+ })
+
+ it('shows live thinking stream inside the process card', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('در حال فکر کردن…')).toBeInTheDocument()
+ expect(screen.getByText(/فروش خالص اسفند صفر است/)).toBeInTheDocument()
+ })
+
+ it('keeps working status when a mid-stream report arrives', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('در حال تکمیل تحلیل…')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '1/2' })).toBeInTheDocument()
+ })
+
+ it('shows the work plan inside the in-chat process card while loading', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('در حال اجرای برنامه کار')).toBeInTheDocument()
+ expect(screen.getByText('بررسی ساختار و واحد ارز فایل')).toBeInTheDocument()
+ expect(screen.getByText('محاسبه مقادیر مشاهدهشده ارزش افزوده با Python')).toBeInTheDocument()
+ expect(screen.getByText('تولید گزارش نتیجه با html_interpreter')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /در حال اجرای برنامه کار/ })).toBeInTheDocument()
+ expect(screen.getByText('1/3')).toBeInTheDocument()
+ })
+
+ it('copies question and answer text', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.assign(navigator, { clipboard: { writeText } })
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'کپی پرسش' }))
+ expect(writeText).toHaveBeenCalledWith('این فایل در مورد چیه')
+
+ fireEvent.click(screen.getByRole('button', { name: 'کپی پاسخ' }))
+ expect(writeText).toHaveBeenCalledWith('یک دفتر مالی است.')
+ })
+
+ it('shows attached knowledge on the user bubble', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('ویراگلد چیست؟')).toBeInTheDocument()
+ expect(screen.getByText('پاسخ فنی واحد معاملات')).toBeInTheDocument()
+ expect(screen.getByText('فضای دانش')).toBeInTheDocument()
+ })
+
+ it('hides attachments when the conversation bar already shows them', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('فروش را تفکیک کن')).toBeInTheDocument()
+ expect(screen.queryByText('Book1 (1).xlsx')).not.toBeInTheDocument()
+ })
+
+ it('shows a full chat analysis together with the HTML report', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/تاریخ تحلیل: امروز/)).toBeInTheDocument()
+ expect(screen.getByText(/خلاصه اجرایی مستقل/)).toBeInTheDocument()
+ expect(screen.getByTitle('html-preview')).toBeInTheDocument()
+ })
+
+ it('hints when a chat answer stops on heading 1', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/پاسخ اینجا قطع شده/)).toBeInTheDocument()
+ })
+
+ it('still hints when truncated text is shown together with an HTML report', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/پاسخ اینجا قطع شده/)).toBeInTheDocument()
+ expect(screen.getByTitle('html-preview')).toBeInTheDocument()
+ })
+})
diff --git a/src/features/assistant/workspace/SessionTurn.tsx b/src/features/assistant/workspace/SessionTurn.tsx
new file mode 100644
index 0000000..07932d3
--- /dev/null
+++ b/src/features/assistant/workspace/SessionTurn.tsx
@@ -0,0 +1,428 @@
+import { useEffect, useRef, useState } from 'react'
+import { BookOpen, Check, ChevronLeft, Copy, Database, RotateCcw, Sparkles, Square } from 'lucide-react'
+import AttachmentMessageCards from '../session-files/AttachmentMessageCards'
+import type { SessionFileSnapshot } from '../session-files/types'
+import type { ChatMessage, ExecutionStep, TaskItem } from '../types/chat'
+import { MarkdownAnswer } from '../components/MarkdownAnswer'
+import { HtmlPreview } from '../components/HtmlPreview'
+import { cleanFinalContent, looksTruncated } from '../lib/final'
+import { TaskPlanList, visibleTasks } from '../components/TaskPlanCard'
+import {
+ FileDownloadCard,
+ type DownloadableFile,
+} from '../artifacts/FileDownloadCard'
+import { t } from '../../../i18n'
+
+function CopyButton({ text, label }: { text: string; label: string }) {
+ const [copied, setCopied] = useState(false)
+
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(text)
+ setCopied(true)
+ window.setTimeout(() => setCopied(false), 1600)
+ } catch {
+ /* clipboard can be unavailable in some browsers */
+ }
+ }
+
+ const copiedLabel = t('assistant.chat.copied')
+
+ return (
+
+ )
+}
+
+function ThinkingCard({
+ steps,
+ tasks,
+ streamText,
+ stopped = false,
+ canRetry = false,
+ onOpen,
+ onRetry,
+}: {
+ steps: ExecutionStep[]
+ tasks?: TaskItem[]
+ streamText?: string
+ stopped?: boolean
+ canRetry?: boolean
+ onOpen?: () => void
+ onRetry?: () => void
+}) {
+ const plan = visibleTasks(tasks ?? [])
+ const planDone = plan.filter((task) => task.status === 'completed').length
+ const completed = steps.filter((step) => step.status === 'done' || step.status === 'completed').length
+ const current = [...steps].reverse().find((step) => step.status === 'running') ?? steps[steps.length - 1]
+ const progress = plan.length
+ ? Math.round((planDone / plan.length) * 100)
+ : steps.length
+ ? Math.round((completed / steps.length) * 100)
+ : 18
+ const countLabel = plan.length ? `${planDone}/${plan.length}` : steps.length ? `${completed}/${steps.length}` : null
+ const currentTitle = current?.title?.trim()
+ const currentOverlapsPlan = Boolean(
+ currentTitle && plan.some((task) => task.content.includes(currentTitle) || currentTitle.includes(task.content)),
+ )
+ const liveText = streamText?.trim()
+ const streamRef = useRef(null)
+
+ useEffect(() => {
+ const el = streamRef.current
+ if (!el || !liveText || stopped) return
+ el.scrollTop = el.scrollHeight
+ }, [liveText, stopped])
+
+ const title = stopped
+ ? t('assistant.execution.stopped')
+ : plan.length
+ ? 'در حال اجرای برنامه کار'
+ : t('assistant.execution.thinking')
+
+ const Tag = stopped ? 'div' : 'button'
+
+ return (
+
+ {stopped ? null : (
+
+ )}
+
+
+ {stopped ? (
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
+
+
+ {title}
+
+ {stopped && canRetry && onRetry ? (
+
+ ) : countLabel ? (
+
+
+ {countLabel}
+
+ ) : null}
+
+ {plan.length && !stopped ? (
+
+ ) : currentTitle && !liveText && !stopped ? (
+
{currentTitle}
+ ) : !liveText && !stopped ? (
+
{t('assistant.execution.processing')}
+ ) : null}
+ {plan.length && currentTitle && !currentOverlapsPlan && !liveText && !stopped ? (
+
{currentTitle}
+ ) : null}
+ {liveText ? (
+
+
+ {liveText}
+ {stopped ? null : (
+
+ )}
+
+
+ ) : stopped ? (
+
{t('assistant.execution.stoppedHint')}
+ ) : null}
+ {stopped ? null : (
+
+ )}
+
+
+
+ )
+}
+
+function ExecutionStatusChip({
+ steps,
+ isWorking,
+ onOpen,
+}: {
+ steps: ExecutionStep[]
+ isWorking: boolean
+ onOpen?: () => void
+}) {
+ if (!steps.length) return null
+
+ const completed = steps.filter((s) => s.status === 'done' || s.status === 'completed').length
+ const label = isWorking
+ ? `${completed}/${steps.length}`
+ : `${t('assistant.execution.viewExecution')} · ${steps.length}`
+
+ return (
+
+ )
+}
+
+function ResourceChip({
+ icon: Icon,
+ label,
+ hint,
+ tone,
+}: {
+ icon: typeof BookOpen
+ label: string
+ hint: string
+ tone: string
+}) {
+ return (
+
+
+
+
+
+ {label}
+ {hint}
+
+
+ )
+}
+
+function TurnAttachments({
+ files,
+ knowledge,
+ database,
+ onPreviewFile,
+}: {
+ files?: readonly SessionFileSnapshot[]
+ knowledge?: ChatMessage['attachedKnowledge']
+ database?: ChatMessage['attachedDb']
+ onPreviewFile?: (file: SessionFileSnapshot) => void
+}) {
+ const hasFiles = Boolean(files?.length)
+ const hasKnowledge = Boolean(knowledge?.name)
+ const hasDb = Boolean(database?.db_name)
+ if (!hasFiles && !hasKnowledge && !hasDb) return null
+ return (
+
+ {hasKnowledge ? (
+
+ ) : null}
+ {hasDb ? (
+
+ ) : null}
+ {hasFiles ?
: null}
+
+ )
+}
+
+export function SessionTurn({
+ userMessage,
+ assistantMessage,
+ attachedFiles,
+ attachedKnowledge,
+ attachedDb,
+ steps = [],
+ taskPlan,
+ isWorking = false,
+ thinkingContent,
+ stopped = false,
+ canRetry = false,
+ htmlReport,
+ reportLoading,
+ downloadFiles,
+ hideAttachments = false,
+ onOpenExecution,
+ onRetry,
+ onPreviewFile,
+}: {
+ userMessage: string
+ assistantMessage?: string
+ attachedFiles?: readonly SessionFileSnapshot[]
+ attachedKnowledge?: ChatMessage['attachedKnowledge']
+ attachedDb?: ChatMessage['attachedDb']
+ steps?: ExecutionStep[]
+ taskPlan?: TaskItem[]
+ isWorking?: boolean
+ thinkingContent?: string
+ stopped?: boolean
+ canRetry?: boolean
+ htmlReport?: string | null
+ reportLoading?: boolean
+ downloadFiles?: readonly DownloadableFile[]
+ hideAttachments?: boolean
+ onOpenExecution?: () => void
+ onRetry?: () => void
+ onPreviewFile?: (file: SessionFileSnapshot) => void
+}) {
+ const question = userMessage.trim()
+ const hasAnswer = Boolean(assistantMessage?.trim())
+ const displayAnswer = hasAnswer ? cleanFinalContent(assistantMessage!) : ''
+ const showCleanAnswer = Boolean(displayAnswer.trim())
+ const showTruncatedHint = showCleanAnswer && looksTruncated(displayAnswer)
+ const liveThinking = thinkingContent?.trim()
+ const showThinking = Boolean(
+ stopped || (isWorking && !hasAnswer && !htmlReport) || (liveThinking && !hasAnswer && !htmlReport),
+ )
+ const stillWorkingAfterReport = isWorking && Boolean(htmlReport) && !hasAnswer
+ const hasAttachments =
+ !hideAttachments && Boolean(attachedFiles?.length || attachedKnowledge?.name || attachedDb?.db_name)
+ const hasUser = Boolean(question || hasAttachments)
+ const hasDownloads = Boolean(downloadFiles?.length)
+ const showAssistant =
+ hasAnswer || htmlReport || reportLoading || showThinking || stillWorkingAfterReport || hasDownloads
+
+ return (
+
+ {hasUser ? (
+
+
+ {question ? (
+
+ ) : null}
+ {hasAttachments ? (
+
+
+
+ ) : null}
+
+
+ ) : null}
+
+ {showAssistant ? (
+
+
+ K
+
+
+ {showThinking ? (
+
+ ) : null}
+
+ {hasAnswer || htmlReport || reportLoading || hasDownloads ? (
+
+ {showCleanAnswer ? (
+
+
+
+ ) : null}
+ {showTruncatedHint ? (
+
{t('assistant.chat.truncatedAnswer')}
+ ) : null}
+ {hasDownloads ? (
+
+ {downloadFiles!.map((file) => (
+
+ ))}
+
+ ) : null}
+ {htmlReport || reportLoading ? (
+
+ {reportLoading && !htmlReport ? (
+
در حال ساخت گزارش…
+ ) : htmlReport ? (
+
+ ) : null}
+
+ ) : null}
+ {stillWorkingAfterReport ? (
+
در حال تکمیل تحلیل…
+ ) : null}
+
+ {showCleanAnswer ? (
+
+ ) : null}
+ {steps.length ? (
+
+ ) : null}
+
+
+ ) : null}
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/assistant/workspace/conversationLayout.ts b/src/features/assistant/workspace/conversationLayout.ts
new file mode 100644
index 0000000..085f331
--- /dev/null
+++ b/src/features/assistant/workspace/conversationLayout.ts
@@ -0,0 +1,3 @@
+/** Shared width so the thread and composer stay aligned. */
+export const conversationWidthClass =
+ 'mx-auto w-full min-w-0 max-w-5xl px-4 sm:px-6 lg:px-8'
diff --git a/src/features/auth/AuthContext.test.tsx b/src/features/auth/AuthContext.test.tsx
new file mode 100644
index 0000000..73dabc0
--- /dev/null
+++ b/src/features/auth/AuthContext.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import { AuthProvider, useAuth } from './AuthContext'
+
+const { fetchSession } = vi.hoisted(() => ({
+ fetchSession: vi.fn(),
+}))
+
+vi.mock('./api/auth.api', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ fetchSession: (...args: unknown[]) => fetchSession(...args),
+ }
+})
+
+function Probe() {
+ const auth = useAuth()
+ return (
+
+
{auth.status}
+
{auth.user?.user_id ?? ''}
+
+
+ )
+}
+
+describe('AuthProvider', () => {
+ it('does not log out when session refresh fails transiently', async () => {
+ fetchSession
+ .mockResolvedValueOnce({
+ status: 'authenticated',
+ user: { user_id: 'u1', email: 'a@b.com' },
+ activeWorkspace: null,
+ })
+ .mockResolvedValue({ status: 'unavailable' })
+
+ render(
+
+
+ ,
+ )
+
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'))
+ expect(screen.getByTestId('user')).toHaveTextContent('u1')
+
+ await userEvent.click(screen.getByRole('button', { name: 'refresh' }))
+ await waitFor(() => expect(fetchSession.mock.calls.length).toBeGreaterThan(1))
+ expect(screen.getByTestId('status')).toHaveTextContent('authenticated')
+ expect(screen.getByTestId('user')).toHaveTextContent('u1')
+ })
+})
diff --git a/src/features/auth/AuthContext.tsx b/src/features/auth/AuthContext.tsx
new file mode 100644
index 0000000..94097c1
--- /dev/null
+++ b/src/features/auth/AuthContext.tsx
@@ -0,0 +1,181 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react'
+import {
+ fetchSession,
+ loginRequest,
+ logoutRequest,
+ readPersistedUser,
+ sendOtpRequest,
+ verifyOtpRequest,
+ type ActiveWorkspace,
+ type AuthUser,
+ type OtpSendResponse,
+} from './api/auth.api'
+
+type AuthStatus = 'loading' | 'authenticated' | 'anonymous'
+
+type AuthContextValue = {
+ status: AuthStatus
+ user: AuthUser | null
+ activeWorkspace: ActiveWorkspace | null
+ login: (email: string, password: string) => Promise
+ sendOtp: (mobile: string) => Promise
+ verifyOtp: (mobile: string, code: string) => Promise
+ logout: () => Promise
+ refresh: () => Promise
+}
+
+const AuthContext = createContext(null)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [status, setStatus] = useState('loading')
+ const [user, setUser] = useState(null)
+ const [activeWorkspace, setActiveWorkspace] = useState(null)
+
+ const statusRef = useRef('loading')
+
+ const applyStatus = useCallback((next: AuthStatus) => {
+ statusRef.current = next
+ setStatus(next)
+ }, [])
+
+ const refresh = useCallback(async () => {
+ try {
+ const session = await fetchSession()
+ if (session.status === 'authenticated') {
+ setUser(session.user)
+ setActiveWorkspace(session.activeWorkspace)
+ applyStatus('authenticated')
+ return
+ }
+ if (session.status === 'anonymous') {
+ setUser(null)
+ setActiveWorkspace(null)
+ applyStatus('anonymous')
+ return
+ }
+ if (statusRef.current === 'loading') {
+ const persisted = readPersistedUser()
+ if (persisted) {
+ setUser(persisted)
+ applyStatus('authenticated')
+ }
+ }
+ } catch {
+ if (statusRef.current === 'loading') {
+ const persisted = readPersistedUser()
+ if (persisted) {
+ setUser(persisted)
+ applyStatus('authenticated')
+ }
+ }
+ }
+ }, [applyStatus])
+
+ useEffect(() => {
+ let cancelled = false
+ void (async () => {
+ let delay = 800
+ while (!cancelled) {
+ await refresh()
+ if (cancelled || statusRef.current !== 'loading') return
+ await new Promise((resolve) => window.setTimeout(resolve, delay))
+ delay = Math.min(delay * 2, 8000)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [refresh])
+
+ useEffect(() => {
+ const retry = () => {
+ void refresh()
+ }
+ const onVisible = () => {
+ if (document.visibilityState === 'visible') retry()
+ }
+ window.addEventListener('online', retry)
+ document.addEventListener('visibilitychange', onVisible)
+ return () => {
+ window.removeEventListener('online', retry)
+ document.removeEventListener('visibilitychange', onVisible)
+ }
+ }, [refresh])
+
+ // Cookie auth is shared across tabs; sync identity when another tab logs in/out.
+ useEffect(() => {
+ let channel: BroadcastChannel | null = null
+ try {
+ channel = new BroadcastChannel('kamidso.auth')
+ channel.onmessage = () => {
+ void refresh()
+ }
+ } catch {
+ channel = null
+ }
+ return () => {
+ channel?.close()
+ }
+ }, [refresh])
+
+ const notifyAuthChanged = useCallback(() => {
+ try {
+ const channel = new BroadcastChannel('kamidso.auth')
+ channel.postMessage({ type: 'auth-changed' })
+ channel.close()
+ } catch {
+ /* BroadcastChannel unavailable */
+ }
+ }, [])
+
+ const login = useCallback(async (email: string, password: string) => {
+ const result = await loginRequest(email, password)
+ setUser(result.user)
+ await refresh()
+ notifyAuthChanged()
+ }, [notifyAuthChanged, refresh])
+
+ const sendOtp = useCallback(async (mobile: string) => {
+ return sendOtpRequest(mobile)
+ }, [])
+
+ const verifyOtp = useCallback(async (mobile: string, code: string) => {
+ const result = await verifyOtpRequest(mobile, code)
+ setUser(result.user)
+ await refresh()
+ notifyAuthChanged()
+ }, [notifyAuthChanged, refresh])
+
+ const logout = useCallback(async () => {
+ try {
+ await logoutRequest()
+ } finally {
+ setUser(null)
+ setActiveWorkspace(null)
+ applyStatus('anonymous')
+ notifyAuthChanged()
+ }
+ }, [applyStatus, notifyAuthChanged])
+
+ const value = useMemo(
+ () => ({ status, user, activeWorkspace, login, sendOtp, verifyOtp, logout, refresh }),
+ [status, user, activeWorkspace, login, sendOtp, verifyOtp, logout, refresh],
+ )
+
+ return {children}
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext)
+ if (!ctx) throw new Error('useAuth must be used within AuthProvider')
+ return ctx
+}
diff --git a/src/features/auth/RequireAuth.test.tsx b/src/features/auth/RequireAuth.test.tsx
new file mode 100644
index 0000000..cccbe5d
--- /dev/null
+++ b/src/features/auth/RequireAuth.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+import { RequireAuth } from './RequireAuth'
+
+vi.mock('./AuthContext', () => ({
+ useAuth: () => ({ status: 'anonymous' }),
+}))
+
+function LoginProbe() {
+ const location = useLocation()
+ const from = (location.state as { from?: string } | null)?.from ?? ''
+ return {from}
+}
+
+describe('RequireAuth', () => {
+ it('preserves the conversation query on the login redirect', () => {
+ render(
+
+
+
+ panel
+
+ }
+ />
+ } />
+
+ ,
+ )
+ expect(screen.queryByText('panel')).not.toBeInTheDocument()
+ expect(screen.getByTestId('from')).toHaveTextContent('/assistant?id=conv_123')
+ })
+})
diff --git a/src/features/auth/RequireAuth.tsx b/src/features/auth/RequireAuth.tsx
new file mode 100644
index 0000000..de970c4
--- /dev/null
+++ b/src/features/auth/RequireAuth.tsx
@@ -0,0 +1,27 @@
+import { Navigate, useLocation } from 'react-router-dom'
+import { useAuth } from './AuthContext'
+
+export function RequireAuth({ children }: { children: React.ReactNode }) {
+ const { status } = useAuth()
+ const location = useLocation()
+
+ if (status === 'loading') {
+ return (
+
+ در حال بررسی نشست…
+
+ )
+ }
+
+ if (status !== 'authenticated') {
+ return (
+
+ )
+ }
+
+ return children
+}
diff --git a/src/features/auth/api/auth.api.test.ts b/src/features/auth/api/auth.api.test.ts
new file mode 100644
index 0000000..d667261
--- /dev/null
+++ b/src/features/auth/api/auth.api.test.ts
@@ -0,0 +1,76 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { persistUser, fetchSession, readPersistedUser } from './auth.api'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ localStorage.clear()
+})
+
+function jsonResponse(status: number, body: unknown) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: { get: () => 'application/json' },
+ json: async () => body,
+ }
+}
+
+describe('fetchSession', () => {
+ it('keeps the persisted user on a backend blip', async () => {
+ persistUser({ user_id: 'u1', email: 'a@b.com', name: 'Ali' })
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => jsonResponse(502, { message: 'bad gateway' })),
+ )
+ await expect(fetchSession()).resolves.toEqual({ status: 'unavailable' })
+ expect(readPersistedUser()?.user_id).toBe('u1')
+ })
+
+ it('keeps the persisted user on a network drop', async () => {
+ persistUser({ user_id: 'u1', email: 'a@b.com' })
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ throw new TypeError('Failed to fetch')
+ }),
+ )
+ await expect(fetchSession()).resolves.toEqual({ status: 'unavailable' })
+ expect(readPersistedUser()?.user_id).toBe('u1')
+ })
+
+ it('clears the session only on 401', async () => {
+ persistUser({ user_id: 'u1', email: 'a@b.com' })
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => jsonResponse(401, { code: 'AUTH_REQUIRED' })),
+ )
+ await expect(fetchSession()).resolves.toEqual({ status: 'anonymous' })
+ expect(readPersistedUser()).toBeNull()
+ })
+
+ it('returns the authenticated user', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () =>
+ jsonResponse(200, {
+ user: { id: 'u1', email: 'a@b.com', name: 'Ali' },
+ active_workspace: { id: 'ws1', name: 'Main' },
+ }),
+ ),
+ )
+ const session = await fetchSession()
+ expect(session).toEqual({
+ status: 'authenticated',
+ user: {
+ user_id: 'u1',
+ email: 'a@b.com',
+ name: 'Ali',
+ role: undefined,
+ workspace_id: 'ws1',
+ mobile: undefined,
+ permissions: undefined,
+ },
+ activeWorkspace: { id: 'ws1', name: 'Main' },
+ })
+ })
+})
diff --git a/src/features/auth/api/auth.api.ts b/src/features/auth/api/auth.api.ts
new file mode 100644
index 0000000..93b53cd
--- /dev/null
+++ b/src/features/auth/api/auth.api.ts
@@ -0,0 +1,159 @@
+import { ApiError, apiFetch } from '../../../services/api'
+
+export type AuthUser = {
+ user_id: string
+ email: string
+ name?: string
+ role?: string
+ workspace_id?: string
+ mobile?: string
+ permissions?: Record
+}
+
+export type ActiveWorkspace = {
+ id: string
+ organization_id?: string
+ name: string
+}
+
+export type MeResponse = {
+ user: AuthUser
+ active_workspace?: ActiveWorkspace
+ organization?: { id: string; name: string }
+ features?: Record
+ developer_mode?: boolean
+ product?: string
+}
+
+export type OtpSendResponse = {
+ ok: boolean
+ message?: string
+ dev_code?: string
+}
+
+const USER_INFO_KEY = 'user_info'
+const KAMIDSO_USER_KEY = 'kamidso_user'
+
+export function persistUser(user: AuthUser) {
+ localStorage.setItem(
+ USER_INFO_KEY,
+ JSON.stringify({
+ user_id: user.user_id,
+ user_no: user.user_id,
+ nick_name: user.name || user.email || user.mobile,
+ email: user.email,
+ mobile: user.mobile,
+ role: user.role,
+ workspace_id: user.workspace_id,
+ }),
+ )
+ localStorage.setItem(
+ KAMIDSO_USER_KEY,
+ JSON.stringify({
+ id: user.user_id,
+ email: user.email,
+ name: user.name,
+ mobile: user.mobile,
+ role: user.role,
+ workspace_id: user.workspace_id,
+ }),
+ )
+}
+
+export function readPersistedUser(): AuthUser | null {
+ try {
+ const raw = localStorage.getItem(KAMIDSO_USER_KEY) || localStorage.getItem(USER_INFO_KEY)
+ if (!raw) return null
+ return mapUser(JSON.parse(raw) as Record)
+ } catch {
+ return null
+ }
+}
+
+export function clearPersistedUser() {
+ localStorage.removeItem(USER_INFO_KEY)
+ localStorage.removeItem(KAMIDSO_USER_KEY)
+}
+
+function mapUser(raw: Record | undefined): AuthUser | null {
+ if (!raw) return null
+ const user_id = String(raw.id ?? raw.user_id ?? '')
+ if (!user_id) return null
+ const permissions =
+ raw.permissions && typeof raw.permissions === 'object'
+ ? (raw.permissions as Record)
+ : undefined
+ return {
+ user_id,
+ email: String(raw.email ?? ''),
+ name: raw.name ? String(raw.name) : undefined,
+ role: raw.role ? String(raw.role) : undefined,
+ workspace_id: raw.workspace_id ? String(raw.workspace_id) : undefined,
+ mobile: raw.mobile ? String(raw.mobile) : undefined,
+ permissions,
+ }
+}
+
+export type SessionResult =
+ | { status: 'authenticated'; user: AuthUser; activeWorkspace: ActiveWorkspace | null }
+ | { status: 'anonymous' }
+ | { status: 'unavailable' }
+
+export async function fetchSession(): Promise {
+ try {
+ const data = await apiFetch('/auth/session')
+ const user = mapUser(data.user as unknown as Record)
+ if (!user?.user_id) {
+ clearPersistedUser()
+ return { status: 'anonymous' }
+ }
+ if (data.active_workspace?.id) user.workspace_id = data.active_workspace.id
+ persistUser(user)
+ return { status: 'authenticated', user, activeWorkspace: data.active_workspace ?? null }
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 401) {
+ clearPersistedUser()
+ return { status: 'anonymous' }
+ }
+ return { status: 'unavailable' }
+ }
+}
+
+export async function loginRequest(email: string, password: string): Promise<{ user: AuthUser }> {
+ const data = await apiFetch<{ user: Record }>('/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ email, password }),
+ })
+ const user = mapUser(data.user as unknown as Record)
+ if (!user) throw new Error('ورود انجام نشد.')
+ persistUser(user)
+ return { user }
+}
+
+export async function sendOtpRequest(mobile: string): Promise {
+ return apiFetch('/auth/otp/send', {
+ method: 'POST',
+ body: JSON.stringify({ mobile }),
+ })
+}
+
+export async function verifyOtpRequest(mobile: string, code: string): Promise<{ user: AuthUser }> {
+ const data = await apiFetch<{ user: Record }>('/auth/otp/verify', {
+ method: 'POST',
+ body: JSON.stringify({ mobile, code }),
+ })
+ const user = mapUser(data.user as unknown as Record)
+ if (!user) throw new Error('ورود انجام نشد.')
+ persistUser(user)
+ return { user }
+}
+
+export async function logoutRequest(): Promise {
+ await apiFetch('/auth/logout', { method: 'POST' })
+ clearPersistedUser()
+ try {
+ sessionStorage.removeItem('kamidso.activeRunByConv')
+ } catch {
+ /* ignore private mode */
+ }
+}
diff --git a/src/features/auth/pages/LoginPage.tsx b/src/features/auth/pages/LoginPage.tsx
new file mode 100644
index 0000000..5757765
--- /dev/null
+++ b/src/features/auth/pages/LoginPage.tsx
@@ -0,0 +1,325 @@
+import { useEffect, useState, type FormEvent } from 'react'
+import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
+import { ArrowLeft, Lock, Mail, Smartphone } from 'lucide-react'
+import { ApiError } from '../../../services/api'
+import { useAuth } from '../AuthContext'
+
+type LoginMode = 'sms' | 'email'
+type SmsStep = 'mobile' | 'code'
+
+export function LoginPage() {
+ const { status, login, sendOtp, verifyOtp } = useAuth()
+ const navigate = useNavigate()
+ const location = useLocation()
+ const from =
+ (location.state as { from?: string } | null)?.from &&
+ !(location.state as { from?: string }).from?.startsWith('/login')
+ ? (location.state as { from: string }).from
+ : '/assistant'
+
+ const [mode, setMode] = useState('sms')
+ const [smsStep, setSmsStep] = useState('mobile')
+ const [mobile, setMobile] = useState('')
+ const [code, setCode] = useState('')
+ const [devCode, setDevCode] = useState(null)
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+
+ useEffect(() => {
+ const previous = document.title
+ document.title = 'ورود — Kamidso'
+ return () => {
+ document.title = previous
+ }
+ }, [])
+
+ if (status === 'authenticated') {
+ return
+ }
+
+ function goAfterLogin() {
+ navigate(from, { replace: true })
+ }
+
+ async function requestOtp() {
+ setError(null)
+ setSubmitting(true)
+ try {
+ const result = await sendOtp(mobile.trim())
+ setDevCode(result.dev_code ?? null)
+ setSmsStep('code')
+ setCode('')
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError('ارسال کد انجام نشد. دوباره تلاش کنید.')
+ }
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ async function onSendOtp(event: FormEvent) {
+ event.preventDefault()
+ await requestOtp()
+ }
+
+ async function onVerifyOtp(event: FormEvent) {
+ event.preventDefault()
+ setError(null)
+ setSubmitting(true)
+ try {
+ await verifyOtp(mobile.trim(), code.trim())
+ goAfterLogin()
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError('ورود انجام نشد. دوباره تلاش کنید.')
+ }
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ async function onEmailSubmit(event: FormEvent) {
+ event.preventDefault()
+ setError(null)
+ setSubmitting(true)
+ try {
+ await login(email.trim(), password)
+ goAfterLogin()
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setError(err.message)
+ } else {
+ setError('ورود انجام نشد. دوباره تلاش کنید.')
+ }
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ K
+
+
+ Kamidso
+ راهبر هوشمند داده
+
+
+
+
+
+
ورود به پنل
+
+ {mode === 'sms'
+ ? 'با شماره موبایل وارد شوید. هر کاربر محیط و دادههای خودش را دارد.'
+ : 'ورود ادمین با ایمیل و رمز عبور.'}
+
+
+ {mode === 'sms' && smsStep === 'mobile' ? (
+
+ ) : null}
+
+ {mode === 'sms' && smsStep === 'code' ? (
+
+ ) : null}
+
+ {mode === 'email' ? (
+
+ ) : null}
+
+
+ {mode === 'sms' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ صفحه اصلی
+
+
+
+
+ )
+}
diff --git a/src/features/auth/useLogoutConfirm.test.tsx b/src/features/auth/useLogoutConfirm.test.tsx
new file mode 100644
index 0000000..1ef984a
--- /dev/null
+++ b/src/features/auth/useLogoutConfirm.test.tsx
@@ -0,0 +1,58 @@
+import { render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+import { useLogoutConfirm } from './useLogoutConfirm'
+
+const logout = vi.fn(async () => {})
+
+vi.mock('./AuthContext', () => ({
+ useAuth: () => ({ logout }),
+}))
+
+function Probe() {
+ const { ask, dialog } = useLogoutConfirm()
+ return (
+ <>
+
+ {dialog}
+ >
+ )
+}
+
+describe('useLogoutConfirm', () => {
+ it('asks before logging out and can be cancelled', async () => {
+ const user = userEvent.setup()
+ logout.mockClear()
+ render(
+
+
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'خروج' }))
+ expect(screen.getByText('مطمئن هستید که میخواهید از حساب کاربری خارج شوید؟')).toBeInTheDocument()
+ expect(logout).not.toHaveBeenCalled()
+
+ await user.click(screen.getByRole('button', { name: 'انصراف' }))
+ expect(logout).not.toHaveBeenCalled()
+ expect(screen.queryByText('مطمئن هستید که میخواهید از حساب کاربری خارج شوید؟')).not.toBeInTheDocument()
+ })
+
+ it('logs out only after confirmation', async () => {
+ const user = userEvent.setup()
+ logout.mockClear()
+ render(
+
+
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'خروج' }))
+ const dialog = screen.getByRole('dialog', { name: 'خروج از حساب' })
+ await user.click(within(dialog).getByRole('button', { name: 'خروج' }))
+ expect(logout).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/features/auth/useLogoutConfirm.tsx b/src/features/auth/useLogoutConfirm.tsx
new file mode 100644
index 0000000..66d43a4
--- /dev/null
+++ b/src/features/auth/useLogoutConfirm.tsx
@@ -0,0 +1,44 @@
+import { useCallback, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
+import { useAuth } from './AuthContext'
+
+export function useLogoutConfirm() {
+ const { logout } = useAuth()
+ const navigate = useNavigate()
+ const [open, setOpen] = useState(false)
+ const [busy, setBusy] = useState(false)
+
+ const ask = useCallback(() => setOpen(true), [])
+ const cancel = useCallback(() => {
+ if (busy) return
+ setOpen(false)
+ }, [busy])
+
+ const confirm = useCallback(async () => {
+ setBusy(true)
+ try {
+ await logout()
+ navigate('/login', { replace: true })
+ } finally {
+ setBusy(false)
+ setOpen(false)
+ }
+ }, [logout, navigate])
+
+ const dialog = (
+ void confirm()}
+ onCancel={cancel}
+ />
+ )
+
+ return { ask, dialog }
+}
diff --git a/src/features/data-map/DataMapPage.tsx b/src/features/data-map/DataMapPage.tsx
new file mode 100644
index 0000000..eeb489c
--- /dev/null
+++ b/src/features/data-map/DataMapPage.tsx
@@ -0,0 +1,559 @@
+import { useEffect, useRef, useState } from 'react'
+import { Database, FileSpreadsheet, Loader2, Network, Sparkles, Square, Wand2 } from 'lucide-react'
+import { Button } from '../../components/Button'
+import { PageHeader } from '../../components/ui/PageHeader'
+import { PageShell } from '../../components/ui/PageShell'
+import { t } from '../../i18n'
+import { listDatasourceRecords, type DatasourceRecord } from '../databases/api'
+import { listWorkspaceFiles, type KamidsoFile } from '../assistant/session-files'
+import { cancelDiscovery, createEdge, deleteEdge, listEdges, listMaps, listSemanticReview, liveHighlightFrom, setEdgeStatus, streamAgent, wrapDiscovery, type LiveHighlight, type TraceEvent } from './api'
+import { DataMapCanvas } from './components/DataMapCanvas'
+import { FieldSemanticPanel } from './components/FieldSemanticPanel'
+import { SemanticReviewList } from './components/SemanticReviewList'
+import { TracePanel } from './components/TracePanel'
+import type { DataEdge, FieldTarget, SemanticReviewItem, SourceMap } from './types'
+import { discoveryProgress, locateField } from './types'
+
+type ResourceRow = { id: string; name: string; kind: string }
+type SuggestScope = 'all' | 'internal' | 'external'
+
+function fieldIdFromHandle(handle: string | null | undefined): string {
+ if (!handle) return ''
+ return handle.replace(/^(in|out):/, '')
+}
+
+function isSourceMap(value: unknown): value is SourceMap {
+ return Boolean(value && typeof value === 'object' && 'resource_id' in value && 'datasets' in value)
+}
+
+function ModelMark({ ratio }: { ratio: number }) {
+ const turn = Math.max(0.08, Math.min(ratio || 0.08, 1))
+ return (
+
+
+
+
+ )
+}
+
+function resourceLabel(map: SourceMap | undefined, running: boolean, known: boolean): string {
+ const progress = discoveryProgress(map)
+ if (map?.status === 'discovering' || running) {
+ if (progress && progress.total) {
+ return t('dataMap.discoveringProgress').replace('{done}', String(progress.done)).replace('{total}', String(progress.total))
+ }
+ return t('dataMap.discovering')
+ }
+ return known ? t('dataMap.discovered') : t('dataMap.notDiscovered')
+}
+
+export function DataMapPage() {
+ const [maps, setMaps] = useState([])
+ const [edges, setEdges] = useState([])
+ const [review, setReview] = useState([])
+ const [resources, setResources] = useState([])
+ const [selected, setSelected] = useState(null)
+ const [error, setError] = useState(null)
+ const [notice, setNotice] = useState(null)
+ const [busy, setBusy] = useState(null)
+ const [trace, setTrace] = useState([])
+ const [traceCollapsed, setTraceCollapsed] = useState(false)
+ const [highlight, setHighlight] = useState(null)
+ const [hold, setHold] = useState<{ id: string; mode: 'cancel' | 'wrap' } | null>(null)
+ const [suggestScope, setSuggestScope] = useState('all')
+ const attached = useRef(new Set())
+
+ function upsertMap(next: SourceMap) {
+ setMaps((current) => {
+ const others = current.filter((item) => item.resource_id !== next.resource_id)
+ if (next.status !== 'ready' && next.status !== 'discovering') return others
+ if (next.status === 'discovering' && !next.datasets.length) {
+ const inventory = Array.isArray(next.coverage_detail?.inventory) ? next.coverage_detail.inventory : []
+ if (!inventory.length) return others
+ }
+ return [...others, next]
+ })
+ }
+
+ async function reload() {
+ const [nextMaps, nextEdges, nextReview, files, databases] = await Promise.all([
+ listMaps(),
+ listEdges(),
+ listSemanticReview(),
+ listWorkspaceFiles(),
+ listDatasourceRecords(),
+ ])
+ const visible = nextMaps.filter((item) => {
+ if (item.status === 'ready') return true
+ if (item.status !== 'discovering') return false
+ const inventory = Array.isArray(item.coverage_detail?.inventory) ? item.coverage_detail.inventory : []
+ return item.datasets.length > 0 || inventory.length > 0
+ })
+ setMaps(visible)
+ setEdges(nextEdges)
+ setReview(nextReview)
+ setResources([
+ ...files.map((file: KamidsoFile) => ({
+ id: file.id,
+ name: file.title?.trim() || file.name,
+ kind: file.kind || 'file',
+ })),
+ ...databases.map((item: DatasourceRecord) => ({
+ id: item.id,
+ name: item.name,
+ kind: item.type,
+ })),
+ ])
+ setSelected((current) => {
+ if (!current) return null
+ return locateField(visible, current.field.id)
+ })
+ for (const item of visible) {
+ if (item.status === 'discovering') {
+ void attachDiscovery(item.resource_id, { fresh: false })
+ }
+ }
+ }
+
+ useEffect(() => {
+ reload().catch((err: Error) => setError(err.message))
+ }, [])
+
+ async function attachDiscovery(resourceId: string, opts?: { fresh?: boolean }) {
+ const path = `/data-graph/resources/${encodeURIComponent(resourceId)}/discover/stream`
+ if (attached.current.has(resourceId)) {
+ setBusy(resourceId)
+ setTraceCollapsed(false)
+ return
+ }
+ attached.current.add(resourceId)
+ setBusy(resourceId)
+ setError(null)
+ setNotice(null)
+ setTraceCollapsed(false)
+ setSelected(null)
+ if (opts?.fresh !== false) {
+ setTrace([{ type: 'status', message: 'شروع شد' }])
+ setHighlight(null)
+ }
+ try {
+ const result = await streamAgent(path, (event) => {
+ const nextHighlight = liveHighlightFrom(event)
+ if (nextHighlight) {
+ setHighlight(nextHighlight)
+ return
+ }
+ if (event.type === 'map' && isSourceMap(event.result)) {
+ upsertMap(event.result)
+ return
+ }
+ if (event.type === 'done') {
+ if (isSourceMap(event.result)) upsertMap(event.result)
+ return
+ }
+ setTrace((current) => {
+ const last = current[current.length - 1]
+ if (event.type === 'status' && last?.type === 'status' && last.message === event.message) {
+ return current
+ }
+ return [...current, event]
+ })
+ })
+ if (isSourceMap(result)) upsertMap(result)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'اجرای Agent ناموفق بود')
+ } finally {
+ attached.current.delete(resourceId)
+ setHighlight(null)
+ setHold((current) => (current?.id === resourceId ? null : current))
+ setBusy((current) => (current === resourceId ? null : current))
+ }
+ await reload()
+ }
+
+ async function stopModeling(resourceId: string, mode: 'cancel' | 'wrap') {
+ setHold({ id: resourceId, mode })
+ setError(null)
+ setNotice(mode === 'wrap' ? 'جمعبندی تا همینجا شروع شد.' : 'توقف کشف شروع شد.')
+ try {
+ const result = await (mode === 'wrap' ? wrapDiscovery(resourceId) : cancelDiscovery(resourceId))
+ if (isSourceMap(result)) {
+ upsertMap(result)
+ setHold(null)
+ await reload()
+ }
+ } catch (err) {
+ setHold(null)
+ setError(err instanceof Error ? err.message : 'توقف ناموفق بود')
+ }
+ }
+
+ async function runSuggest() {
+ setBusy('suggest')
+ setError(null)
+ setNotice(null)
+ setTraceCollapsed(false)
+ setSelected(null)
+ setTrace([{ type: 'status', message: 'شروع شد' }])
+ setHighlight(null)
+ try {
+ let suggestions = 0
+ await streamAgent(
+ '/data-graph/suggestions/stream',
+ (event) => {
+ const nextHighlight = liveHighlightFrom(event)
+ if (nextHighlight) {
+ setHighlight(nextHighlight)
+ return
+ }
+ if (event.type === 'done') {
+ suggestions = Array.isArray(event.result) ? event.result.length : suggestions
+ return
+ }
+ setTrace((current) => {
+ const last = current[current.length - 1]
+ if (event.type === 'status' && last?.type === 'status' && last.message === event.message) {
+ return current
+ }
+ return [...current, event]
+ })
+ },
+ { scope: suggestScope },
+ )
+ await reload()
+ if (suggestions === 0) {
+ setNotice(t('dataMap.suggestNone'))
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'اجرای Agent ناموفق بود')
+ } finally {
+ setHighlight(null)
+ setBusy(null)
+ }
+ }
+
+ const suggestions = edges.filter((edge) => edge.status === 'suggested')
+ const confirmedLinks = edges.filter((edge) => edge.status === 'confirmed')
+ const discovering = maps.some((item) => item.status === 'discovering') || (busy !== null && busy !== 'suggest')
+ const hasTraceWork = busy !== null || trace.length > 0 || discovering
+ const showTracePanel = !selected && hasTraceWork && !traceCollapsed && busy !== 'suggest'
+ const showAside = Boolean(selected) || showTracePanel
+ const showTraceTab = !selected && hasTraceWork && traceCollapsed && busy !== 'suggest'
+ const readyCount = maps.filter((item) => item.status === 'ready').length
+ const hasInternal = maps.some((item) => item.status === 'ready' && item.datasets.length >= 2)
+ const canSuggest =
+ busy !== 'suggest' &&
+ (suggestScope === 'internal' ? hasInternal : suggestScope === 'external' ? readyCount >= 2 : hasInternal || readyCount >= 2)
+ const suggestHint = !canSuggest && busy !== 'suggest'
+ ? suggestScope === 'internal'
+ ? t('dataMap.suggestNeedInternal')
+ : t('dataMap.suggestNeedTwo')
+ : suggestScope === 'internal'
+ ? t('dataMap.suggestHintInternal')
+ : suggestScope === 'external'
+ ? t('dataMap.suggestHintExternal')
+ : t('dataMap.suggestHintAll')
+ const currentStep = busy === 'suggest' || ((hasInternal || readyCount >= 2) && !suggestions.length) ? 2 : suggestions.length ? 3 : 1
+
+ return (
+
+
+
+
+
+ >
+ }
+ action={
+
+
+ {(['all', 'internal', 'external'] as SuggestScope[]).map((scope) => (
+
+ ))}
+
+
+
{suggestHint}
+ {suggestions.length ? (
+
+ {t('dataMap.suggestPending').replace('{count}', String(suggestions.length))}
+
+ ) : null}
+
+ }
+ />
+ {error ? {error}
: null}
+ {notice ? {notice}
: null}
+ setSelected(locateField(maps, fieldId))}
+ onChanged={reload}
+ onError={setError}
+ />
+
+
+
+
{t('dataMap.sourcesTitle')}
+
{t('dataMap.sourcesHint')}
+
+
+ {resources.length ? (
+
+ {resources.map((resource) => {
+ const map = maps.find((item) => item.resource_id === resource.id)
+ const known = Boolean(map)
+ const running = busy === resource.id || map?.status === 'discovering'
+ const file = resource.kind === 'excel' || resource.kind === 'csv' || resource.kind === 'json'
+ const Icon = file ? FileSpreadsheet : Database
+ const progress = discoveryProgress(map)
+ const ratio = progress && progress.total ? progress.done / progress.total : 0.08
+ const pausing = hold?.id === resource.id ? hold.mode : null
+ const statusText = pausing === 'wrap'
+ ? t('dataMap.wrapping')
+ : pausing === 'cancel'
+ ? t('dataMap.stopping')
+ : resourceLabel(map, running, known)
+ return (
+
+ {running ? : null}
+
+ {known && !running ? (
+
+ ) : null}
+ {running ? (
+
+
+
+
+ ) : null}
+
+ )
+ })}
+
+ ) : (
+
+ {t('dataMap.sourcesEmpty')}
+
+ )}
+
+ {maps.length ? (
+
+
{t('dataMap.canvasHint')}
+
+
+
+ {t('dataMap.legendSuggested')}
+ {suggestions.length ? ` (${suggestions.length})` : ''}
+
+
+
+ {t('dataMap.legendConfirmed')}
+ {confirmedLinks.length ? ` (${confirmedLinks.length})` : ''}
+
+
+
+ ) : null}
+
+ {maps.length ? (
+
{
+ const from = fieldIdFromHandle(connection.sourceHandle)
+ const to = fieldIdFromHandle(connection.targetHandle)
+ if (!from || !to || from === to) return
+ void createEdge(from, to).then(() => reload()).catch((err: Error) => setError(err.message))
+ }}
+ onDeleteEdge={(edgeId) => {
+ void deleteEdge(edgeId).then(() => reload()).catch((err: Error) => setError(err.message))
+ }}
+ onConfirmEdge={(edgeId) => {
+ void setEdgeStatus(edgeId, 'confirmed').then(() => reload()).catch((err: Error) => setError(err.message))
+ }}
+ />
+ ) : (
+
+ {t('dataMap.canvasEmpty')}
+
+ )}
+ {selected ? (
+ setSelected(null)}
+ onChanged={reload}
+ onError={setError}
+ />
+ ) : null}
+ {showTracePanel ? (
+
+ {
+ setTrace([])
+ setTraceCollapsed(false)
+ }}
+ onCollapse={() => setTraceCollapsed(true)}
+ />
+
+ ) : null}
+
+ {showTraceTab ? (
+
+ ) : null}
+
+ )
+}
+
+function StepChip({ active, label }: { active: boolean; label: string }) {
+ return (
+
+ {label}
+
+ )
+}
+
+function kindLabel(kind: string): string {
+ const labels: Record = {
+ excel: 'Excel',
+ csv: 'CSV',
+ json: 'JSON',
+ sqlite: 'SQLite',
+ sql: 'SQL',
+ postgres: 'Postgres',
+ mysql: 'MySQL',
+ mongodb: 'MongoDB',
+ openapi: 'API',
+ }
+ return labels[kind] || kind || 'منبع'
+}
diff --git a/src/features/data-map/api.test.ts b/src/features/data-map/api.test.ts
new file mode 100644
index 0000000..55a7696
--- /dev/null
+++ b/src/features/data-map/api.test.ts
@@ -0,0 +1,82 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { liveHighlightFrom, streamAgent } from './api'
+
+function readerFrom(chunks: string[]) {
+ const encoder = new TextEncoder()
+ let index = 0
+ return {
+ read: async () => {
+ if (index >= chunks.length) return { done: true, value: undefined }
+ const value = encoder.encode(chunks[index])
+ index += 1
+ return { done: false, value }
+ },
+ }
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('liveHighlightFrom', () => {
+ it('keeps the field the model is writing', () => {
+ expect(
+ liveHighlightFrom({
+ type: 'highlight',
+ field: 'emal_price',
+ meaning: 'The exercise price',
+ label: 'exercise_strike_price',
+ count: 2,
+ recent: [{ name: 'created_at', meaning: 'created' }, { name: 'emal_price', meaning: 'The exercise price' }],
+ }),
+ ).toMatchObject({ field: 'emal_price', meaning: 'The exercise price', count: 2, kind: 'field' })
+ })
+
+ it('keeps the link the model is writing', () => {
+ expect(
+ liveHighlightFrom({
+ type: 'highlight',
+ kind: 'link',
+ from: 'orders.customer_id',
+ to: 'customers.id',
+ meaning: 'same customer',
+ label: 'foreign_key',
+ count: 1,
+ }),
+ ).toMatchObject({ kind: 'link', from: 'orders.customer_id', to: 'customers.id', meaning: 'same customer' })
+ })
+})
+
+describe('streamAgent', () => {
+ it('treats a dropped stream as background continuation', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({
+ ok: true,
+ body: { getReader: () => readerFrom(['data: {"type":"status","message":"شروع شد"}\n\n']) },
+ })),
+ )
+ const events: string[] = []
+ await expect(streamAgent('/data-graph/x', (event) => events.push(event.type))).resolves.toBeUndefined()
+ expect(events).toEqual(['status', 'status'])
+ })
+
+ it('resolves only after the done event', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({
+ ok: true,
+ body: {
+ getReader: () =>
+ readerFrom([
+ 'data: {"type":"status","message":"شروع شد"}\n\n',
+ 'data: {"type":"done","result":{"resource_id":"file"}}\n\n',
+ ]),
+ },
+ })),
+ )
+ const events: string[] = []
+ await streamAgent('/data-graph/x', (event) => events.push(event.type))
+ expect(events).toEqual(['status', 'done'])
+ })
+})
diff --git a/src/features/data-map/api.ts b/src/features/data-map/api.ts
new file mode 100644
index 0000000..7c1c7e6
--- /dev/null
+++ b/src/features/data-map/api.ts
@@ -0,0 +1,197 @@
+import { apiFetchEnvelope, kamidsoUrl } from '../../services/api'
+import type { DataEdge, FieldFacts, SemanticReviewItem, SourceDataset, SourceMap } from './types'
+
+export type LiveField = {
+ name: string
+ meaning: string
+}
+
+export type LiveHighlight = {
+ kind: 'field' | 'link'
+ thought: string
+ field: string
+ meaning: string
+ label: string
+ from: string
+ to: string
+ count: number
+ recent: LiveField[]
+}
+
+export type TraceEvent = {
+ type: 'status' | 'call' | 'observation' | 'done' | 'error' | 'map' | 'highlight'
+ message?: string
+ action?: string
+ input?: string
+ output?: string
+ result?: unknown
+ thought?: string
+ field?: string
+ meaning?: string
+ label?: string
+ kind?: 'field' | 'link'
+ from?: string
+ to?: string
+ count?: number
+ recent?: LiveField[]
+}
+
+export function liveHighlightFrom(event: TraceEvent): LiveHighlight | null {
+ if (event.type !== 'highlight') return null
+ const field = event.field?.trim() || ''
+ const thought = event.thought?.trim() || ''
+ const from = event.from?.trim() || ''
+ const to = event.to?.trim() || ''
+ const kind = event.kind === 'link' || from || to ? 'link' : 'field'
+ if (kind === 'field' && !field && !thought) return null
+ if (kind === 'link' && !from && !to && !thought) return null
+ return {
+ kind,
+ thought,
+ field,
+ meaning: event.meaning?.trim() || '',
+ label: event.label?.trim() || '',
+ from,
+ to,
+ count: event.count || 0,
+ recent: Array.isArray(event.recent) ? event.recent.filter((item) => item?.name) : [],
+ }
+}
+
+export async function streamAgent(
+ path: string,
+ onEvent: (event: TraceEvent) => void,
+ body: Record = {},
+): Promise {
+ const response = await fetch(kamidsoUrl(path), {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
+ body: JSON.stringify(body),
+ })
+ if (!response.ok || !response.body) {
+ const data = (await response.json().catch(() => null)) as { detail?: { message?: string }; message?: string } | null
+ throw new Error(data?.detail?.message || data?.message || 'درخواست ناموفق بود')
+ }
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+ let result: unknown
+ let finished = false
+ while (true) {
+ const { value, done } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+ const chunks = buffer.split('\n\n')
+ buffer = chunks.pop() || ''
+ for (const chunk of chunks) {
+ const line = chunk.split('\n').find((item) => item.startsWith('data:'))
+ if (!line) continue
+ let event: TraceEvent
+ try {
+ event = JSON.parse(line.slice(5).trim()) as TraceEvent
+ } catch {
+ continue
+ }
+ onEvent(event)
+ if (event.type === 'error') throw new Error(event.message || 'اجرای Agent ناموفق بود')
+ if (event.type === 'done') {
+ finished = true
+ result = event.result
+ }
+ }
+ }
+ if (!finished) {
+ onEvent({ type: 'status', message: 'در پسزمینه ادامه دارد' })
+ return undefined
+ }
+ return result
+}
+
+export function listMaps(): Promise {
+ return apiFetchEnvelope('/data-graph/maps')
+}
+
+export function listEdges(): Promise {
+ return apiFetchEnvelope('/data-graph/edges')
+}
+
+export function cancelDiscovery(resourceId: string): Promise {
+ return apiFetchEnvelope(`/data-graph/resources/${encodeURIComponent(resourceId)}/discover/cancel`, {
+ method: 'POST',
+ body: JSON.stringify({}),
+ })
+}
+
+export function wrapDiscovery(resourceId: string): Promise {
+ return apiFetchEnvelope(`/data-graph/resources/${encodeURIComponent(resourceId)}/discover/wrap`, {
+ method: 'POST',
+ body: JSON.stringify({}),
+ })
+}
+
+export function discoverResource(resourceId: string): Promise {
+ return apiFetchEnvelope(`/data-graph/resources/${encodeURIComponent(resourceId)}/discover`, {
+ method: 'POST',
+ body: JSON.stringify({}),
+ })
+}
+
+export function updateMap(resourceId: string, datasets: SourceDataset[]): Promise {
+ return apiFetchEnvelope(`/data-graph/maps/${encodeURIComponent(resourceId)}`, {
+ method: 'PUT',
+ body: JSON.stringify({ datasets }),
+ })
+}
+
+export function createEdge(fromField: string, toField: string): Promise {
+ return apiFetchEnvelope('/data-graph/edges', {
+ method: 'POST',
+ body: JSON.stringify({ from_field: fromField, to_field: toField, type: 'custom' }),
+ })
+}
+
+export function deleteEdge(edgeId: string): Promise<{ id: string }> {
+ return apiFetchEnvelope<{ id: string }>(`/data-graph/edges/${encodeURIComponent(edgeId)}`, {
+ method: 'DELETE',
+ })
+}
+
+export function setEdgeStatus(edgeId: string, status: 'confirmed' | 'rejected'): Promise {
+ return apiFetchEnvelope(`/data-graph/edges/${encodeURIComponent(edgeId)}`, {
+ method: 'PATCH',
+ body: JSON.stringify({ status }),
+ })
+}
+
+export function suggestEdges(): Promise {
+ return apiFetchEnvelope('/data-graph/suggestions', {
+ method: 'POST',
+ body: JSON.stringify({}),
+ })
+}
+
+export function listSemanticReview(): Promise