Add Didar connections and a data hub for attaching sources.

The assistant can pick connectors and show subagent steps, and the databases page is replaced by connections, sources, and a dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
javadandCursor committed 2026-10-12 19:26:46 +03:30
1 parent 13c6e7ade6
commit b17782eec2
59 files changed
+2945 -574

No files matched your search

+3 -1
View File
@@ -18,7 +18,9 @@ server {
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 "";
# Variable proxy_pass plus keepalive reuses a socket that is still
# writing SSE. The next chat is written onto that socket and both die.
proxy_set_header Connection "close";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+17 -1
View File
@@ -4,7 +4,12 @@ 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('/data')).toBe('داشبورد داده')
expect(pageTitleFromPath('/data/files')).toBe('فایل‌ها')
expect(pageTitleFromPath('/data/databases')).toBe('دیتابیس')
expect(pageTitleFromPath('/data/connectors')).toBe('اتصالات')
expect(pageTitleFromPath('/data/map')).toBe('نقشه سازمان')
expect(pageTitleFromPath('/databases')).toBe('دیتابیس')
expect(pageTitleFromPath('/knowledge')).toBe('دانش')
expect(pageTitleFromPath('/skills')).toBe('مهارت‌ها')
expect(pageTitleFromPath('/settings')).toBe('تنظیمات')
@@ -14,4 +19,15 @@ describe('app navigation', () => {
expect(PRIMARY_NAV.some((item) => item.id === 'skills' && item.to === '/skills')).toBe(true)
expect(isNavActive('/skills', { to: '/skills', match: ['/skills'] })).toBe(true)
})
it('keeps sources, connections, and the data map under one داده item', () => {
const data = PRIMARY_NAV.find((item) => item.id === 'data')
expect(data?.to).toBe('/data')
expect(data && isNavActive('/data', data)).toBe(true)
expect(data && isNavActive('/data/files', data)).toBe(true)
expect(data && isNavActive('/data/databases', data)).toBe(true)
expect(data && isNavActive('/data/connectors', data)).toBe(true)
expect(data && isNavActive('/data/map', data)).toBe(true)
expect(data && isNavActive('/data-map', data)).toBe(true)
})
})
+7 -5
View File
@@ -2,7 +2,7 @@ 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 NavId = 'assistant' | 'data' | 'knowledge' | 'skills' | 'settings'
export type NavItem = {
id: NavId
@@ -14,8 +14,7 @@ export type NavItem = {
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: 'data', to: '/data', label: 'داده', match: ['/data', '/data-map', '/databases'] },
{ id: 'knowledge', to: '/knowledge', label: 'دانش', match: ['/knowledge'] },
{ id: 'skills', to: '/skills', label: 'مهارت‌ها', match: ['/skills'] },
]
@@ -29,8 +28,11 @@ export const SETTINGS_NAV: NavItem = {
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('/data/map') || pathname.startsWith('/data-map')) return 'نقشه سازمان'
if (pathname.startsWith('/data/files') || pathname.startsWith('/data/sources')) return 'فایل‌ها'
if (pathname.startsWith('/data/databases') || pathname.startsWith('/data/connections') || pathname.startsWith('/databases')) return 'دیتابیس'
if (pathname.startsWith('/data/connectors') || pathname.startsWith('/settings/connectors')) return 'اتصالات'
if (pathname.startsWith('/data')) return 'داشبورد داده'
if (pathname.startsWith('/knowledge')) return 'دانش'
if (pathname.startsWith('/skills')) return 'مهارت‌ها'
if (pathname.startsWith('/settings')) return 'تنظیمات'
@@ -23,6 +23,7 @@ export type FeatureFlags = {
widgets: boolean
radar: boolean
new_agent_workspace: boolean
connectors: boolean
}
const DEFAULT_FLAGS: FeatureFlags = {
@@ -38,6 +39,7 @@ const DEFAULT_FLAGS: FeatureFlags = {
widgets: false,
radar: false,
new_agent_workspace: true,
connectors: false,
}
type FeatureFlagContextValue = {
@@ -101,3 +103,7 @@ export function useFeatureFlags() {
if (!ctx) throw new Error('useFeatureFlags must be used within FeatureFlagProvider')
return ctx
}
export function useFeatureFlagsOptional() {
return useContext(FeatureFlagContext)
}
+17 -3
View File
@@ -1,14 +1,18 @@
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 { ConnectionsPage } from '../features/data/ConnectionsPage'
import { DataDashboardPage } from '../features/data/DataDashboardPage'
import { DataHubLayout } from '../features/data/DataHubLayout'
import { SourcesPage } from '../features/data/SourcesPage'
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 { ConnectorsPage } from '../features/connectors/ConnectorsPage'
import { LandingPage } from '../features/landing/pages/LandingPage'
import { ShareReportPage } from '../pages/ShareReportPage'
import { AuthProvider } from '../features/auth/AuthContext'
@@ -37,13 +41,23 @@ export function AppRouter() {
<Routes>
<Route path="/home" element={<Navigate to="/assistant" replace />} />
<Route path="/assistant" element={<AssistantPage />} />
<Route path="/data-map" element={<DataMapPage />} />
<Route path="/databases" element={<DatabasesPage />} />
<Route path="/data" element={<DataHubLayout />}>
<Route index element={<DataDashboardPage />} />
<Route path="files" element={<SourcesPage />} />
<Route path="databases" element={<ConnectionsPage />} />
<Route path="connectors" element={<ConnectorsPage />} />
<Route path="map" element={<DataMapPage />} />
<Route path="sources" element={<Navigate to="/data/files" replace />} />
<Route path="connections" element={<Navigate to="/data/databases" replace />} />
</Route>
<Route path="/data-map" element={<Navigate to="/data/map" replace />} />
<Route path="/databases" element={<Navigate to="/data/databases" replace />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/knowledge/:id" element={<KnowledgeDetailPage />} />
<Route path="/skills" element={<SkillsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/organization" element={<OrganizationProfilePage />} />
<Route path="/settings/connectors" element={<Navigate to="/data/connectors" replace />} />
<Route path="/settings/skills" element={<Navigate to="/skills" replace />} />
<Route path="/settings/skills/:skillId" element={<SkillSettingsPage />} />
<Route path="*" element={<Navigate to="/assistant" replace />} />
+5 -4
View File
@@ -3,7 +3,6 @@ import { useCallback, useEffect, useRef } from 'react'
import {
BookOpen,
Database,
Network,
Loader2,
LogOut,
MessageSquarePlus,
@@ -19,8 +18,7 @@ import { useLogoutConfirm } from '../../features/auth/useLogoutConfirm'
const ICONS = {
assistant: Sparkles,
'data-map': Network,
databases: Database,
data: Database,
knowledge: BookOpen,
skills: WandSparkles,
} as const
@@ -117,7 +115,10 @@ export function AppSidebar({
) : null}
<NavLink
to="/assistant"
onClick={onNavigate}
onClick={(event) => {
event.preventDefault()
goNewChat()
}}
className={`flex min-w-0 items-center gap-2.5 rounded-xl px-1 py-1 ${compact ? 'justify-center' : 'flex-1'}`}
>
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-violet-500 text-sm font-bold text-white shadow-[0_8px_18px_-10px_rgba(37,99,235,0.8)]">
+4 -5
View File
@@ -1,25 +1,24 @@
import { BookOpen, Database, House, Network, Settings, Sparkles, WandSparkles } from 'lucide-react'
import { BookOpen, Database, House, 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,
data: Database,
knowledge: BookOpen,
skills: WandSparkles,
settings: Settings,
} as const
const MOBILE_IDS = ['assistant', 'data-map', 'databases', 'knowledge', 'skills'] as const
const MOBILE_IDS = ['assistant', 'data', 'knowledge', 'skills'] as const
export function MobileNav() {
const { pathname } = useLocation()
const items = PRIMARY_NAV.filter((item) => MOBILE_IDS.includes(item.id as (typeof MOBILE_IDS)[number]))
return (
<nav className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t border-line bg-panel/95 px-1 pb-[env(safe-area-inset-bottom)] pt-1 shadow-[0_-8px_24px_-16px_rgba(23,32,51,0.35)] backdrop-blur md:hidden">
<nav className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-4 border-t border-line bg-panel/95 px-1 pb-[env(safe-area-inset-bottom)] pt-1 shadow-[0_-8px_24px_-16px_rgba(23,32,51,0.35)] backdrop-blur md:hidden">
{items.map((item) => {
const Icon = ICONS[item.id]
const active = isNavActive(pathname, item)
+1 -3
View File
@@ -3,7 +3,6 @@ import {
BookOpen,
ChevronRight,
Database,
Network,
House,
PanelRightClose,
Settings,
@@ -15,8 +14,7 @@ import { isNavActive, PRIMARY_NAV, SIDEBAR_COLLAPSED, SIDEBAR_EXPANDED } from '.
const ICONS = {
home: House,
assistant: Sparkles,
'data-map': Network,
databases: Database,
data: Database,
knowledge: BookOpen,
skills: WandSparkles,
settings: Settings,
+9 -2
View File
@@ -10,6 +10,7 @@ export type SelectMenuOption = {
description?: string
group?: string
icon?: LucideIcon
mark?: ReactNode
disabled?: boolean
}
@@ -250,7 +251,11 @@ export function SelectMenu({
return (
<div key={option.value || `opt-${index}`}>
{showGroup ? (
<p className={`px-2.5 pb-1 text-[11px] font-medium text-ink-subtle ${index === 0 ? 'pt-0.5' : 'pt-2'}`}>
<p
className={`px-2.5 pb-1 text-[10px] font-semibold tracking-wide text-ink-muted ${
index === 0 ? 'pt-1' : 'mt-1 border-t border-line/80 pt-2'
}`}
>
{option.group}
</p>
) : null}
@@ -270,7 +275,9 @@ export function SelectMenu({
isActive ? 'bg-accent-soft' : 'hover:bg-surface-subtle'
} ${isSelected ? 'text-accent' : 'text-ink'}`}
>
{Icon ? (
{option.mark ? (
<span className="mt-0.5 shrink-0">{option.mark}</span>
) : Icon ? (
<span
className={`mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${
isSelected ? 'bg-accent text-white' : 'bg-surface-subtle text-ink-muted'
@@ -77,6 +77,19 @@ export function refsToDatabases(
.filter((item) => item.db_name)
}
export function refsToConnectors(
refs: HistoryResourceRef[] | undefined,
): NonNullable<ChatMessage['attachedConnectors']> {
return (refs ?? [])
.filter((ref) => refType(ref) === 'connector')
.map((ref) => {
const id = refId(ref)
const name = refName(ref) || id
return { id, name }
})
.filter((item) => item.id || item.name)
}
export function refsToDatabase(
refs: HistoryResourceRef[] | undefined,
): ChatMessage['attachedDb'] | undefined {
@@ -103,6 +116,7 @@ export function collectPinnedResources(messages: readonly ChatMessage[]): {
database?: ChatMessage['attachedDb']
databases: NonNullable<ChatMessage['attachedDatabases']>
knowledge?: ChatMessage['attachedKnowledge']
connectors: NonNullable<ChatMessage['attachedConnectors']>
} {
const files = collectTurnInputFiles(messages, [])
const databases: Array<{ id?: string; db_name: string; db_type: string }> = []
@@ -128,11 +142,23 @@ export function collectPinnedResources(messages: readonly ChatMessage[]): {
const lastHumanWithKnowledge = [...messages]
.reverse()
.find((msg) => msg.role === 'human' && msg.attachedKnowledge?.name)
const connectors: Array<{ id: string; name: string }> = []
const seenConnectors = new Set<string>()
for (const msg of messages) {
if (msg.role !== 'human') continue
for (const item of msg.attachedConnectors ?? []) {
const key = item.id || item.name
if (!key || seenConnectors.has(key)) continue
seenConnectors.add(key)
connectors.push(item)
}
}
return {
files,
database: databases[databases.length - 1],
databases,
knowledge: lastHumanWithKnowledge?.attachedKnowledge,
connectors,
}
}
@@ -171,6 +197,7 @@ export function historyToMessages(
attachedFiles: refsToFiles(msg.resource_refs),
attachedKnowledge: refsToKnowledge(msg.resource_refs),
attachedDatabases: refsToDatabases(msg.resource_refs),
attachedConnectors: refsToConnectors(msg.resource_refs),
attachedDb: refsToDatabase(msg.resource_refs),
})
} else if (role === 'assistant' || role === 'view') {
@@ -199,6 +226,9 @@ export function historyToMessages(
if (!lastHuman.attachedDb) {
lastHuman.attachedDb = refsToDatabase(latestRun.resources)
}
if (!lastHuman.attachedConnectors?.length) {
lastHuman.attachedConnectors = refsToConnectors(latestRun.resources)
}
}
const last = mapped[mapped.length - 1]
@@ -13,6 +13,13 @@ describe('stepTypeLabel', () => {
it('keeps a free-form title that is not a tool slug', () => {
expect(stepTypeLabel('code_interpreter', 'جمع‌بندی فروش')).toBe('جمع‌بندی فروش')
})
it('names a Didar fetch without the tool slug', () => {
expect(stepTypeLabel('connector_call', 'connector_call', 'running')).toBe('در حال دریافت اطلاعات از دیدار…')
expect(stepTypeLabel('connector_call', 'connector_call', 'done')).toBe('اطلاعات دیدار دریافت شد')
expect(stepTypeLabel('connector_call', 'connector_call', 'failed')).toBe('خطا در دریافت اطلاعات از دیدار')
expect(toolActionName('connector_call')).toBe('')
})
})
describe('toolActionName', () => {
@@ -13,6 +13,7 @@ const ENGINE_LABELS: Record<string, string> = {
html_interpreter: 'assistant.execution.htmlBuild',
shell_interpreter: 'assistant.execution.shellCommand',
question: 'assistant.execution.askUser',
connector_call: 'assistant.execution.connectorRunning',
}
const CATEGORY_LABELS: Record<string, string> = {
@@ -27,6 +28,7 @@ const CATEGORY_LABELS: Record<string, string> = {
html_interpreter: 'assistant.panel.categoryHtml',
shell_interpreter: 'assistant.panel.categoryShell',
load_skill: 'assistant.panel.categorySkill',
connector_call: 'assistant.panel.categoryConnector',
}
const DETAIL_LABELS: Record<string, string> = {
@@ -38,9 +40,14 @@ function engineLabelKey(value?: string): string | undefined {
return key ? ENGINE_LABELS[key] : undefined
}
export function stepTypeLabel(action?: string, title?: string): string {
export function stepTypeLabel(action?: string, title?: string, status?: string): string {
const actionKey = action?.trim() || ''
const titleKey = title?.trim() || ''
if (actionKey === 'connector_call' || titleKey === 'connector_call') {
if (status === 'failed') return t('assistant.execution.connectorFailed')
if (status === 'done' || status === 'completed') return t('assistant.execution.connectorDone')
return t('assistant.execution.connectorRunning')
}
const mappedTitle = engineLabelKey(titleKey)
if (mappedTitle) return t(mappedTitle)
if (titleKey && titleKey !== actionKey) return titleKey
+1 -1
View File
@@ -2,7 +2,7 @@ import { kamidsoUrl } from '../../../services/api'
export type ResourceRef = {
id: string
type: 'file' | 'datasource' | 'knowledge'
type: 'file' | 'datasource' | 'knowledge' | 'connector'
name?: string
}
@@ -2,16 +2,8 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createHtmlShare } from '../api/reports.api'
import { downloadReportPdf } from '../report/downloadReportPdf'
import { HtmlPreview } from './HtmlPreview'
vi.mock('../report/downloadReportPdf', async () => {
const actual = await vi.importActual<typeof import('../report/downloadReportPdf')>(
'../report/downloadReportPdf',
)
return { ...actual, downloadReportPdf: vi.fn() }
})
vi.mock('../api/reports.api', () => ({
createHtmlShare: vi.fn(async () => ({
id: 'rpt_abc',
@@ -24,7 +16,6 @@ const sample = "<html dir='rtl' lang='fa'><body><h1>گزارش</h1></body></html
describe('HtmlPreview', () => {
beforeEach(() => {
vi.mocked(downloadReportPdf).mockClear()
vi.mocked(createHtmlShare).mockClear()
})
@@ -33,6 +24,7 @@ describe('HtmlPreview', () => {
render(<HtmlPreview html={sample} />)
expect(screen.getByTitle('html-preview')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'دانلود PDF' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'تمام‌صفحه' }))
expect(screen.getByRole('dialog', { name: 'گزارش تحلیل' })).toBeInTheDocument()
expect(screen.getByTitle('html-preview-full')).toBeInTheDocument()
@@ -41,13 +33,6 @@ describe('HtmlPreview', () => {
expect(screen.queryByRole('dialog', { name: 'گزارش تحلیل' })).not.toBeInTheDocument()
})
it('downloads the report as a print-ready PDF', async () => {
const user = userEvent.setup()
render(<HtmlPreview html={sample} />)
await user.click(screen.getByRole('button', { name: 'دانلود PDF' }))
expect(downloadReportPdf).toHaveBeenCalledWith(sample, 'گزارش تحلیل')
})
it('creates a public share link and copies it', async () => {
const user = userEvent.setup()
const writeText = vi.fn().mockResolvedValue(undefined)
@@ -1,10 +1,10 @@
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 { Check, Copy, 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'
import { reportTitleFromHtml } from '../report/downloadReportPdf'
function ReportFrame({
html,
@@ -49,7 +49,6 @@ export function HtmlPreview({ html }: { html: string }) {
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)
@@ -130,18 +129,10 @@ export function HtmlPreview({ html }: { html: string }) {
}
}
function download() {
downloadReportPdf(html, documentTitle)
}
function toolbar(anchor: ShareAnchor, extra?: ReactNode) {
const shareOpen = shareAnchor === anchor
return (
<div className="flex flex-wrap items-center gap-1">
<button type="button" className={actionClass} onClick={download}>
<Download className="h-3.5 w-3.5" />
{downloadLabel}
</button>
<button
type="button"
className={actionClass}
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { StepTimeline } from './StepTimeline'
describe('StepTimeline sub-agent lane', () => {
it('renders dispatch children in a separate parallel lane', () => {
render(
<StepTimeline
steps={[
{ id: 'lead', title: 'dispatch_parallel_tasks', status: 'running', action: 'dispatch_parallel_tasks' },
{ id: 'subagent:a', title: 'صفحه ۲ معاملات', status: 'running' },
{
id: 'subagent:a:r1',
title: 'دریافت صفحه',
action: 'connector_call',
status: 'running',
detail: 'در حال خواندن معاملات موفق',
},
{ id: 'subagent:b', title: 'جمع مبالغ', status: 'pending' },
]}
/>,
)
const lane = screen.getByTestId('subagent-lane')
expect(lane).toHaveTextContent('کارهای موازی')
expect(lane).toHaveTextContent('صفحه ۲ معاملات')
expect(lane).toHaveTextContent('جمع مبالغ')
expect(lane).toHaveTextContent('در حال خواندن معاملات موفق')
expect(screen.getByText('dispatch_parallel_tasks')).toBeInTheDocument()
expect(lane).not.toHaveTextContent('dispatch_parallel_tasks')
})
})
@@ -1,7 +1,8 @@
import { ChevronDown, ChevronUp } from 'lucide-react'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { ExecutionStep } from '../types/chat'
import { ManusStepCard } from '../execution/ManusStepCard'
import { groupTimeline, SubagentLane, subagentGroupId } from '../execution/SubagentLane'
import { t } from '../../../i18n'
export function StepTimeline({
@@ -16,10 +17,19 @@ export function StepTimeline({
defaultCollapsed?: boolean
}) {
const [collapsed, setCollapsed] = useState(defaultCollapsed)
const listRef = useRef<HTMLUListElement>(null)
const runningId = [...steps].reverse().find((step) => step.status === 'running')?.id
useEffect(() => {
const nodes = listRef.current?.querySelectorAll('[data-running="true"]')
nodes?.[nodes.length - 1]?.scrollIntoView({ block: 'nearest' })
}, [runningId, steps.length])
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
const leadSteps = steps.filter((step) => !subagentGroupId(step.id))
const counted = leadSteps.length ? leadSteps : steps
const runningCount = steps.filter((step) => step.status === 'running').length
const doneCount = counted.filter((step) => step.status === 'done' || step.status === 'completed').length
const rows = groupTimeline(steps)
return (
<div className="rounded-xl border border-line bg-[#f8f9fc] dark:bg-surface-subtle/30">
@@ -31,18 +41,33 @@ export function StepTimeline({
<span className="flex items-center gap-2">
<span>{t('assistant.panel.executionSteps')}</span>
<span className="rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-normal text-ink-muted">
{doneCount}/{steps.length}
{doneCount}/{counted.length}
{runningCount > 0 ? ` · ${t('assistant.execution.processing')}` : ''}
</span>
</span>
{collapsed ? <ChevronDown className="h-4 w-4 text-ink-muted" /> : <ChevronUp className="h-4 w-4 text-ink-muted" />}
</button>
{!collapsed ? (
<ul className="relative space-y-0.5 border-t border-line px-2 py-2">
{steps.map((step, index) => {
const isLast = index === steps.length - 1
<ul ref={listRef} className="relative space-y-0.5 border-t border-line px-2 py-2">
{rows.map((row, index) => {
const isLast = index === rows.length - 1
if (row.type === 'lane') {
return (
<li key={step.id} className="relative ps-1">
<li key={`lane-${row.agents.map((agent) => agent.id).join('-')}`} className="relative py-1 ps-1">
{!isLast ? (
<span className="absolute start-[1.15rem] top-4 bottom-0 w-px bg-violet-200 dark:bg-violet-400/30" aria-hidden />
) : null}
<SubagentLane agents={row.agents} activeStepId={activeStepId} onStepClick={onStepClick} />
</li>
)
}
const step = row.step
return (
<li
key={step.id}
className="relative ps-1"
data-running={step.status === 'running' ? 'true' : undefined}
>
{!isLast ? (
<span className="absolute start-[1.15rem] top-9 bottom-0 w-px bg-line" aria-hidden />
) : null}
@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ExecutionPanel } from './ExecutionPanel'
describe('ExecutionPanel', () => {
it('keeps step logs and omits the html report preview', () => {
render(
<ExecutionPanel
steps={[{ id: 's1', title: 'ساخت گزارش', status: 'done' }]}
outputs={{
s1: [
{ output_type: 'text', content: 'HTML report rendered successfully.' },
{ output_type: 'html', content: '<h1>گزارش بخش پشتیبانی</h1>' },
],
}}
/>,
)
expect(screen.getByText(/HTML report rendered successfully/)).toBeInTheDocument()
expect(screen.queryByTitle('html-preview')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'دانلود PDF' })).not.toBeInTheDocument()
})
})
@@ -1,5 +1,5 @@
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
import { BookOpen, Database, FileText, FolderOpen, Loader2, Monitor, PanelLeftClose } from 'lucide-react'
import { BookOpen, Database, FileText, FolderOpen, Loader2, Monitor, PanelLeftClose, Plug } 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'
@@ -26,6 +26,7 @@ export function ExecutionPanel({
artifacts,
inputFiles = [],
attachedKnowledge,
attachedConnectors = [],
attachedDb,
loading,
developerMode,
@@ -41,6 +42,7 @@ export function ExecutionPanel({
artifacts?: KamidsoArtifact[]
inputFiles?: readonly SessionFileSnapshot[]
attachedKnowledge?: ChatMessage['attachedKnowledge']
attachedConnectors?: readonly { id: string; name: string }[]
attachedDb?: ChatMessage['attachedDb']
loading?: boolean
developerMode?: boolean
@@ -53,8 +55,9 @@ export function ExecutionPanel({
const resolvedStepId = activeStepId ?? localStepId ?? steps[steps.length - 1]?.id ?? null
const activeStep = steps.find((s) => s.id === resolvedStepId)
const stepOutputs = resolvedStepId ? outputs[resolvedStepId] || [] : []
const visibleOutputs = stepOutputs.filter((output) => output.output_type !== 'html')
const fileCount = inputFiles.length + (artifacts?.length ?? 0)
const hasSources = Boolean(attachedKnowledge?.name || attachedDb?.db_name)
const hasSources = Boolean(attachedKnowledge?.name || attachedDb?.db_name || attachedConnectors.length)
useEffect(() => {
if (activeStepId) setLocalStepId(activeStepId)
@@ -133,6 +136,14 @@ export function ExecutionPanel({
</span>
</p>
) : null}
{attachedConnectors.map((item) => (
<p key={item.id || item.name} className="flex items-center gap-2 text-sm text-ink">
<Plug className="h-3.5 w-3.5 shrink-0 text-sky-700" />
<span className="min-w-0 truncate">
{t('assistant.composer.connectorHint')}: {item.name}
</span>
</p>
))}
</div>
</section>
) : null}
@@ -151,18 +162,18 @@ export function ExecutionPanel({
{activeStep ? <StepDetailCard key={activeStep.id} step={activeStep} /> : null}
{stepOutputs.length ? (
{visibleOutputs.length ? (
<div className="overflow-hidden rounded-xl border border-line bg-panel p-3">
<ExecutionOutputRenderer outputs={stepOutputs} developerMode={developerMode} />
<ExecutionOutputRenderer outputs={visibleOutputs} developerMode={developerMode} />
</div>
) : loading ? (
) : loading && !stepOutputs.length ? (
<div className="flex flex-col items-center justify-center py-12 text-ink-muted">
<Loader2 className="mb-3 h-8 w-8 animate-spin text-accent" />
<p className="text-sm">{t('assistant.execution.processing')}</p>
</div>
) : steps.length ? (
) : steps.length && !stepOutputs.length ? (
<p className="text-sm text-ink-muted">{t('assistant.panel.noOutput')}</p>
) : (
) : steps.length ? null : (
<p className="text-sm text-ink-muted">{t('assistant.panel.noSteps')}</p>
)}
@@ -14,7 +14,7 @@ export function ManusStepCard({
onClick?: () => void
compact?: boolean
}) {
const label = stepTypeLabel(step.action, step.title)
const label = stepTypeLabel(step.action, step.title, step.status)
const category = stepCategoryLabel(step.action)
const actionName = toolActionName(step.action)
const Icon = toolIconForStep(step)
@@ -56,6 +56,11 @@ export function ManusStepCard({
{!compact && actionName ? (
<span className="mt-0.5 block truncate font-mono text-[10px] text-ink-muted">{actionName}</span>
) : null}
{step.status === 'running' && step.detail ? (
<span className="mt-0.5 line-clamp-2 whitespace-pre-wrap break-words text-[10px] leading-4 text-ink-muted">
{step.detail.split('\n').filter((line) => line.trim()).slice(-1)[0]}
</span>
) : null}
</span>
</button>
)
@@ -1,32 +1,58 @@
import { ChevronDown, ChevronUp } from 'lucide-react'
import { useState } from 'react'
import { useEffect, useRef, 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'
import { t } from '../../../i18n'
export function StepDetailCard({ step }: { step: ExecutionStep }) {
const [collapsed, setCollapsed] = useState(false)
const detailRef = useRef<HTMLDivElement>(null)
const Icon = toolIconForStep(step)
const category = stepCategoryLabel(step.action)
const actionName = toolActionName(step.action)
const title = stepTypeLabel(step.action, step.title)
const title = stepTypeLabel(step.action, step.title, step.status)
const detail = stepDetailLabel(step.detail)
const subagent = step.id.startsWith('subagent:')
useEffect(() => {
const el = detailRef.current
if (!el || step.status !== 'running') return
el.scrollTop = el.scrollHeight
}, [detail, step.status])
return (
<div className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
<div
className={`overflow-hidden rounded-xl border shadow-sm ${
subagent
? 'border-violet-200 bg-gradient-to-b from-violet-50/80 to-panel dark:border-violet-400/30 dark:from-violet-500/10'
: 'border-line bg-panel'
}`}
>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-start transition hover:bg-surface-subtle/60"
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent">
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
subagent
? 'bg-gradient-to-br from-violet-500 to-indigo-500 text-white'
: 'bg-accent-soft text-accent'
}`}
>
<Icon className={`h-4 w-4 ${step.status === 'running' ? 'animate-spin' : ''}`} />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wide text-accent">{category}</span>
<span
className={`text-[10px] font-semibold uppercase tracking-wide ${
subagent ? 'text-violet-600 dark:text-violet-200' : 'text-accent'
}`}
>
{subagent ? t('assistant.panel.subagent') : category}
</span>
{actionName ? (
<span className="rounded bg-surface-subtle px-1.5 py-0.5 font-mono text-[10px] text-ink-muted">
{actionName}
@@ -46,7 +72,10 @@ export function StepDetailCard({ step }: { step: ExecutionStep }) {
</div>
</button>
{!collapsed && detail ? (
<div className="border-t border-line px-4 py-3 text-xs leading-6 text-ink-muted whitespace-pre-wrap">
<div
ref={detailRef}
className="max-h-48 overflow-y-auto border-t border-line px-4 py-3 text-xs leading-6 whitespace-pre-wrap text-ink-muted"
>
{detail}
</div>
) : null}
@@ -0,0 +1,230 @@
import { CheckCircle2, GitBranch, Loader2, XCircle } from 'lucide-react'
import type { ExecutionStep } from '../types/chat'
import { stepTypeLabel } from '../adapters/stepLabels'
import { toolIconForStep } from './toolIcons'
import { t } from '../../../i18n'
export type SubagentGroup = {
id: string
header: ExecutionStep | null
tools: ExecutionStep[]
}
export type TimelineRow =
| { type: 'step'; step: ExecutionStep }
| { type: 'lane'; agents: SubagentGroup[] }
export function subagentGroupId(id: string): string | null {
if (!id.startsWith('subagent:')) return null
const rest = id.slice('subagent:'.length)
if (!rest) return null
const cut = rest.indexOf(':')
return cut === -1 ? rest : rest.slice(0, cut)
}
export function isSubagentHeader(id: string) {
const groupId = subagentGroupId(id)
return groupId != null && id === `subagent:${groupId}`
}
export function groupTimeline(steps: ExecutionStep[]): TimelineRow[] {
const rows: TimelineRow[] = []
let lane: SubagentGroup[] | null = null
const flush = () => {
if (lane?.length) rows.push({ type: 'lane', agents: lane })
lane = null
}
for (const step of steps) {
const groupId = subagentGroupId(step.id)
if (!groupId) {
flush()
rows.push({ type: 'step', step })
continue
}
if (!lane) lane = []
let group = lane.find((item) => item.id === groupId)
if (!group) {
group = { id: groupId, header: null, tools: [] }
lane.push(group)
}
if (isSubagentHeader(step.id)) group.header = step
else group.tools.push(step)
}
flush()
return rows
}
function liveLine(detail?: string) {
const lines = (detail ?? '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
return lines[lines.length - 1] ?? ''
}
function isDone(status: ExecutionStep['status']) {
return status === 'done' || status === 'completed'
}
export function SubagentLane({
agents,
activeStepId,
onStepClick,
}: {
agents: SubagentGroup[]
activeStepId?: string | null
onStepClick?: (stepId: string) => void
}) {
const tasks = agents.flatMap((agent) => [agent.header, ...agent.tools].filter(Boolean) as ExecutionStep[])
const done = tasks.filter((step) => isDone(step.status)).length
const running = tasks.some((step) => step.status === 'running')
return (
<div
className="relative overflow-hidden rounded-2xl border border-violet-200/90 bg-gradient-to-b from-violet-50 via-white to-indigo-50/60 p-2 shadow-[0_14px_32px_-22px_rgba(109,40,217,0.7)] dark:border-violet-400/25 dark:from-violet-500/15 dark:via-[#14151c] dark:to-indigo-500/10"
data-testid="subagent-lane"
>
<span
aria-hidden
className="pointer-events-none absolute -start-6 -top-8 h-24 w-24 rounded-full bg-violet-400/20 blur-2xl"
/>
<div className="relative mb-2 flex items-center justify-between gap-2 px-1">
<span className="inline-flex min-w-0 items-center gap-1.5 text-[11px] font-semibold text-violet-800 dark:text-violet-100">
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-gradient-to-br from-violet-500 to-indigo-500 text-white shadow-[0_6px_12px_-8px_rgba(109,40,217,0.9)]">
<GitBranch className="h-3 w-3" />
</span>
<span className="truncate">{t('assistant.panel.parallelTasks')}</span>
</span>
<span className="shrink-0 rounded-full bg-violet-100 px-2 py-0.5 text-[10px] font-medium text-violet-700 dark:bg-violet-500/20 dark:text-violet-100">
{done}/{tasks.length}
{running ? ` · ${t('assistant.execution.processing')}` : ''}
</span>
</div>
<div className="relative space-y-2">
{agents.map((agent, index) => (
<AgentCard
key={agent.id}
agent={agent}
index={index}
activeStepId={activeStepId}
onStepClick={onStepClick}
/>
))}
</div>
</div>
)
}
function AgentCard({
agent,
index,
activeStepId,
onStepClick,
}: {
agent: SubagentGroup
index: number
activeStepId?: string | null
onStepClick?: (stepId: string) => void
}) {
const header = agent.header
const runningTool = [...agent.tools].reverse().find((step) => step.status === 'running')
const running = header?.status === 'running' || Boolean(runningTool)
const failed = header?.status === 'failed' || agent.tools.some((step) => step.status === 'failed')
const title = header?.title?.trim() || t('assistant.panel.subagent')
const headerLive = !runningTool && running ? liveLine(header?.detail) : ''
const headerId = header?.id
return (
<article
className={`relative overflow-hidden rounded-xl border bg-white/85 dark:bg-[#101117]/80 ${
running
? 'border-violet-300 shadow-[0_10px_24px_-16px_rgba(109,40,217,0.85)] dark:border-violet-400/40'
: 'border-violet-100 dark:border-violet-400/15'
}`}
data-running={running ? 'true' : undefined}
>
{running ? <span aria-hidden className="think-card-sheen pointer-events-none absolute inset-0 opacity-70" /> : null}
<button
type="button"
onClick={() => headerId && onStepClick?.(headerId)}
className={`relative flex w-full items-center gap-2 px-2 py-2 text-start ${
headerId && headerId === activeStepId ? 'bg-violet-100/70 dark:bg-violet-500/15' : ''
}`}
>
<span className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-indigo-500 text-[11px] font-bold text-white">
{running ? <span aria-hidden className="think-orb-pulse absolute inset-0 rounded-full bg-violet-400/80" /> : null}
<span className="relative">{index + 1}</span>
</span>
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-semibold text-violet-600 dark:text-violet-200">
{t('assistant.panel.subagent')}
</span>
<span className="block truncate text-xs font-semibold text-ink">{title}</span>
</span>
{failed ? (
<XCircle className="h-3.5 w-3.5 shrink-0 text-danger" />
) : running ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-violet-600" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-600" />
)}
</button>
{headerLive ? (
<p className="relative mx-2 mb-2 line-clamp-3 rounded-lg bg-violet-50 px-2 py-1.5 text-[11px] leading-5 text-violet-950 dark:bg-violet-500/10 dark:text-violet-50">
{headerLive}
</p>
) : null}
{agent.tools.length ? (
<ol className="relative mx-3 mb-2 space-y-1.5 border-s border-dashed border-violet-300 ps-3 dark:border-violet-400/40">
{agent.tools.map((step) => {
const Icon = toolIconForStep({ ...step, status: step.status === 'running' ? 'pending' : step.status })
const live = step.status === 'running' ? liveLine(step.detail) : ''
const active = step.id === activeStepId
return (
<li key={step.id} className="relative" data-running={step.status === 'running' ? 'true' : undefined}>
<span
aria-hidden
className={`absolute -start-[1.05rem] top-2.5 h-2.5 w-2.5 rounded-full ring-2 ring-white dark:ring-[#101117] ${
step.status === 'failed'
? 'bg-rose-500'
: step.status === 'running'
? 'bg-violet-500'
: 'bg-emerald-500'
}`}
/>
<button
type="button"
onClick={() => onStepClick?.(step.id)}
className={`flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-start text-[11px] transition ${
active
? 'bg-violet-100 text-violet-950 ring-1 ring-violet-300 dark:bg-violet-500/20 dark:text-violet-50'
: 'bg-violet-50/70 text-ink hover:bg-violet-100/80 dark:bg-white/5 dark:hover:bg-white/10'
}`}
>
{step.status === 'running' ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-violet-600" />
) : (
<Icon className="h-3 w-3 shrink-0 text-violet-600 dark:text-violet-200" />
)}
<span className="min-w-0 flex-1 truncate font-medium">
{stepTypeLabel(step.action, step.title, step.status)}
</span>
</button>
{live ? (
<p className="mt-1 line-clamp-2 rounded-lg bg-white px-2 py-1 text-[10px] leading-4 text-violet-900 dark:bg-black/20 dark:text-violet-100">
{live}
</p>
) : null}
</li>
)
})}
</ol>
) : null}
{running ? (
<span className="relative mx-2 mb-2 block h-1 overflow-hidden rounded-full bg-violet-100 dark:bg-violet-500/20">
<span className="think-progress-bar block h-full w-2/3 rounded-full" />
</span>
) : null}
</article>
)
}
@@ -6,6 +6,7 @@ import {
FileSpreadsheet,
Loader2,
Search,
Plug,
Terminal,
Wrench,
type LucideIcon,
@@ -19,6 +20,7 @@ const ACTION_ICONS: Record<string, LucideIcon> = {
file_reader: FileSearch,
sql_executor: Database,
knowledge_search: Search,
connector_call: Plug,
read_file: FileSearch,
write_file: FileSpreadsheet,
run_python: Code2,
@@ -1,5 +1,5 @@
import { Paperclip, Send, Square } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { t } from '../../../i18n'
import AttachmentRail from '../session-files/AttachmentRail'
import type { DraftFile, LegacyServerFile } from '../session-files'
@@ -7,6 +7,27 @@ import { conversationWidthClass } from '../workspace/conversationLayout'
import { DATA_FILE_ACCEPT } from '../session-files/fileKind'
import type { ResourceOption } from '../types/chat'
export type EnhancedChatInputProps = {
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[]
pinnedConnectors?: readonly ResourceOption[]
pinnedKnowledge?: ResourceOption | null
onRemoveDatabase?: (id: string) => void
onRemoveConnector?: (id: string) => void
onClearKnowledge?: () => void
toolbar?: ReactNode
variant?: 'hero' | 'dock'
}
export function EnhancedChatInput({
query,
onQueryChange,
@@ -19,29 +40,14 @@ export function EnhancedChatInput({
onRetryFile,
onAddFiles,
pinnedDatabases = [],
pinnedConnectors = [],
pinnedKnowledge = null,
onRemoveDatabase,
onRemoveConnector,
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'
}) {
}: EnhancedChatInputProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [focused, setFocused] = useState(false)
@@ -51,6 +57,7 @@ export function EnhancedChatInput({
drafts.length ||
(legacyFiles?.length ?? 0) ||
pinnedDatabases.length ||
pinnedConnectors.length ||
pinnedKnowledge,
)
@@ -83,10 +90,12 @@ export function EnhancedChatInput({
drafts={drafts}
legacyFiles={legacyFiles}
databases={pinnedDatabases}
connectors={pinnedConnectors}
knowledge={pinnedKnowledge}
onRemove={onRemoveFile}
onRetry={onRetryFile}
onRemoveDatabase={onRemoveDatabase}
onRemoveConnector={onRemoveConnector}
onRemoveKnowledge={onClearKnowledge}
addControl={null}
/>
@@ -101,4 +101,29 @@ describe('ResourcePicker', () => {
expect(onToggle).toHaveBeenCalledWith(sales)
expect(screen.getByRole('listbox', { name: 'داده' })).toBeInTheDocument()
})
it('attaches an active business connector', async () => {
const onSelectConnectors = vi.fn()
render(
<ResourcePicker
databases={[]}
knowledge={[]}
selectedDbs={[]}
selectedKnowledge={null}
onSelectDbs={vi.fn()}
onSelectKnowledge={vi.fn()}
connectors={[{ id: 'conn_1', name: 'دیدار فروش', type: 'didar' }]}
selectedConnectors={[]}
onSelectConnectors={onSelectConnectors}
/>,
)
expect(screen.queryByRole('button', { name: 'اتصال‌ها' })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'داده' }))
expect(screen.getByText('اتصالات')).toBeInTheDocument()
await userEvent.click(screen.getByRole('option', { name: /دیدار فروش/ }))
expect(onSelectConnectors).toHaveBeenCalledWith([
{ id: 'conn_1', name: 'دیدار فروش', type: 'didar' },
])
})
})
+111 -45
View File
@@ -1,13 +1,28 @@
import { BookOpen, Database, FileSpreadsheet, Upload } from 'lucide-react'
import { BookOpen, Database, FileSpreadsheet, Plug, Upload } from 'lucide-react'
import { t } from '../../../i18n'
import { SelectMenu } from '../../../components/ui/SelectMenu'
import { SelectMenu, type SelectMenuOption } from '../../../components/ui/SelectMenu'
import { ENGINES } from '../../databases/engines'
import { DidarMark } from '../../connectors/DidarMark'
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 DB_PREFIX = 'conn:'
const FILE_PREFIX = 'file:'
const APP_PREFIX = 'app:'
function databaseLabel(type?: string) {
if (!type) return t('assistant.composer.dataConnection')
const id = type === 'postgres' ? 'postgresql' : type
return ENGINES.find((engine) => engine.id === id)?.label || type
}
function appLabel(type?: string) {
if (!type) return t('assistant.composer.connectorHint')
if (type.toLowerCase() === 'didar') return 'دیدار'
return type
}
export function ResourcePicker({
databases,
@@ -20,6 +35,9 @@ export function ResourcePicker({
attachedFileIds = [],
onToggleLibraryFile,
onUploadNew,
connectors = [],
selectedConnectors = [],
onSelectConnectors,
}: {
databases: ResourceOption[]
knowledge: ResourceOption[]
@@ -31,21 +49,87 @@ export function ResourcePicker({
attachedFileIds?: readonly string[]
onToggleLibraryFile?: (file: KamidsoFile) => void
onUploadNew?: () => void
connectors?: ResourceOption[]
selectedConnectors?: ResourceOption[]
onSelectConnectors?: (items: ResourceOption[]) => 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),
...selectedDbs.map((item) => item.name),
...selectedConnectors.map((item) => item.name),
]
const dataActive = names.length > 0
const selectedDataValues = dataActive
? [
...selectedDbs.map((item) => `${CONN_PREFIX}${item.id}`),
...attached.map((file) => `${FILE_PREFIX}${file.id}`),
...selectedDbs.map((item) => `${DB_PREFIX}${item.id}`),
...selectedConnectors.map((item) => `${APP_PREFIX}${item.id}`),
]
: ['']
const dataValue = selectedDataValues[0] && selectedDataValues[0] !== '' ? selectedDataValues[0] : ''
: []
const dataValue = selectedDataValues[0] || ''
const onlyFiles = attached.length > 0 && selectedDbs.length === 0 && selectedConnectors.length === 0
const onlyApps = selectedConnectors.length > 0 && attached.length === 0 && selectedDbs.length === 0
const dataOptions: SelectMenuOption[] = [
...dataFiles.map((file) => ({
value: `${FILE_PREFIX}${file.id}`,
label: file.title?.trim() || file.name,
description: fileKindLine(file.kind),
group: t('assistant.composer.dataFiles'),
icon: FileSpreadsheet,
})),
...databases.map((item) => ({
value: `${DB_PREFIX}${item.id}`,
label: item.name,
description: databaseLabel(item.type),
group: t('assistant.composer.dataConnections'),
icon: Database,
})),
...(onSelectConnectors
? connectors.map((item) => ({
value: `${APP_PREFIX}${item.id}`,
label: item.name,
description: appLabel(item.type),
group: t('assistant.composer.dataApps'),
mark:
item.type?.toLowerCase() === 'didar' ? (
<DidarMark className="h-7 w-7 rounded-lg" />
) : undefined,
icon: item.type?.toLowerCase() === 'didar' ? undefined : Plug,
}))
: []),
]
function onDataChange(id: string) {
if (!id) {
onSelectDbs([])
attached.forEach((file) => onToggleLibraryFile?.(file))
onSelectConnectors?.([])
return
}
if (id.startsWith(DB_PREFIX)) {
const next = databases.find((item) => item.id === id.slice(DB_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)
return
}
if (id.startsWith(APP_PREFIX) && onSelectConnectors) {
const next = connectors.find((item) => item.id === id.slice(APP_PREFIX.length))
if (!next) return
const exists = selectedConnectors.some((item) => item.id === next.id)
onSelectConnectors(
exists ? selectedConnectors.filter((item) => item.id !== next.id) : [...selectedConnectors, next],
)
}
}
return (
<div className="flex flex-wrap items-center gap-1.5">
@@ -57,43 +141,12 @@ export function ResourcePicker({
displayLabel={t('assistant.composer.data')}
placeholder={t('assistant.composer.data')}
closeOnSelect={false}
options={[
{ value: '', label: t('assistant.composer.dataNone') },
...databases.map((item) => ({
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)
}
}}
options={dataOptions}
onChange={onDataChange}
leading={
<ToolbarChipIcon
icon={attached.length ? FileSpreadsheet : Database}
tone={attached.length ? 'files' : selectedDbs.length ? 'database' : 'muted'}
icon={onlyFiles ? FileSpreadsheet : onlyApps ? Plug : Database}
tone={onlyFiles ? 'files' : onlyApps ? 'apps' : selectedDbs.length ? 'database' : dataActive ? 'files' : 'muted'}
/>
}
trailing={
@@ -103,19 +156,32 @@ export function ResourcePicker({
</span>
) : null
}
minMenuWidth={280}
minMenuWidth={300}
emptyLabel={t('assistant.composer.dataEmpty')}
triggerClassName={toolbarChipClass(dataActive)}
footer={
onUploadNew ? (
onUploadNew || dataActive ? (
<div className="flex items-center gap-1">
{onUploadNew ? (
<button
type="button"
onClick={onUploadNew}
className="flex h-9 w-full items-center justify-center gap-2 rounded-xl text-xs font-medium text-accent transition hover:bg-accent-soft"
className="flex h-8 min-w-0 flex-1 items-center justify-center gap-2 rounded-xl text-xs font-medium text-accent transition hover:bg-white"
>
<Upload className="h-3.5 w-3.5" />
{t('assistant.composer.uploadData')}
</button>
) : null}
{dataActive ? (
<button
type="button"
onClick={() => onDataChange('')}
className="h-8 shrink-0 rounded-xl px-3 text-xs font-medium text-ink-muted transition hover:bg-white hover:text-ink"
>
{t('assistant.composer.clearData')}
</button>
) : null}
</div>
) : null
}
/>
@@ -24,6 +24,9 @@ export function ToolsBar({
attachedFileIds = [],
onToggleLibraryFile,
onUploadNew,
connectors = [],
selectedConnectors = [],
onSelectConnectors,
}: {
databases: ResourceOption[]
knowledge: ResourceOption[]
@@ -42,6 +45,9 @@ export function ToolsBar({
attachedFileIds?: readonly string[]
onToggleLibraryFile?: (file: KamidsoFile) => void
onUploadNew?: () => void
connectors?: ResourceOption[]
selectedConnectors?: ResourceOption[]
onSelectConnectors?: (items: ResourceOption[]) => void
}) {
return (
<div className="flex w-full flex-wrap items-center gap-1.5">
@@ -56,6 +62,9 @@ export function ToolsBar({
attachedFileIds={attachedFileIds}
onToggleLibraryFile={onToggleLibraryFile}
onUploadNew={onUploadNew}
connectors={connectors}
selectedConnectors={selectedConnectors}
onSelectConnectors={onSelectConnectors}
/>
<SkillSelector skills={skills} value={selectedSkill} onChange={onSelectSkill} />
{onReportModeChange ? (
@@ -6,6 +6,7 @@ const TONE = {
files: 'bg-accent-soft text-accent',
knowledge: 'bg-warn-soft text-warn',
skill: 'bg-info-soft text-info',
apps: 'bg-violet-50 text-violet-700',
} as const
export function ToolbarChipIcon({
@@ -1,4 +1,4 @@
import { AlertCircle, BookOpen, CheckCircle2, Database, Loader2, Paperclip, RefreshCw, X } from 'lucide-react'
import { AlertCircle, BookOpen, CheckCircle2, Database, Loader2, Paperclip, Plug, RefreshCw, X } from 'lucide-react'
import type { ReactNode } from 'react'
import type { DraftFile, LegacyServerFile } from './types'
import { formatBytes, toLegacyRailItem, toRailItems } from './attachment-view-model'
@@ -6,15 +6,18 @@ import { t } from '../../../i18n'
export type RailDatabase = { id: string; name: string; type?: string }
export type RailKnowledge = { id: string; name: string }
export type RailConnector = { id: string; name: string }
export interface AttachmentRailProps {
drafts: readonly DraftFile[]
legacyFiles?: readonly LegacyServerFile[]
databases?: readonly RailDatabase[]
connectors?: readonly RailConnector[]
knowledge?: RailKnowledge | null
onRemove?: (clientId: string) => void
onRetry?: (clientId: string) => void
onRemoveDatabase?: (id: string) => void
onRemoveConnector?: (id: string) => void
onRemoveKnowledge?: () => void
addControl?: ReactNode
className?: string
@@ -37,10 +40,12 @@ export default function AttachmentRail({
drafts,
legacyFiles = [],
databases = [],
connectors = [],
knowledge = null,
onRemove,
onRetry,
onRemoveDatabase,
onRemoveConnector,
onRemoveKnowledge,
addControl,
className = '',
@@ -52,6 +57,7 @@ export default function AttachmentRail({
draftItems.length === 0 &&
legacyItems.length === 0 &&
databases.length === 0 &&
connectors.length === 0 &&
!hasKnowledge &&
!addControl
) {
@@ -84,6 +90,30 @@ export default function AttachmentRail({
) : null}
</div>
))}
{connectors.map((item) => (
<div
key={`connector-${item.id}`}
className="inline-flex max-w-full items-center gap-2 rounded-xl bg-white px-2 py-1.5 ring-1 ring-black/5"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sky-50 text-sky-700">
<Plug className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1 pe-1">
<p className="truncate text-xs font-medium text-ink">{item.name}</p>
<p className="text-[10px] text-ink-muted">{t('assistant.composer.connectorHint')}</p>
</div>
{onRemoveConnector ? (
<button
type="button"
onClick={() => onRemoveConnector(item.id)}
className="text-ink-muted hover:text-danger"
aria-label={t('assistant.files.remove')}
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
))}
{hasKnowledge ? (
<div className="inline-flex max-w-full items-center gap-2 rounded-xl bg-white px-2 py-1.5 ring-1 ring-black/5">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-amber-50 text-amber-700">
@@ -3,6 +3,7 @@ import {
downloadFilesFromArtifacts,
mergeDownloadFiles,
seedFileOutputs,
seedTurnOutputs,
} from './downloadFiles'
describe('downloadFiles helpers', () => {
@@ -32,6 +33,30 @@ describe('downloadFiles helpers', () => {
expect(outputs.__exports[0].output_type).toBe('file')
})
it('seeds the html report next to file downloads', () => {
const html = "<html dir='rtl' lang='fa'><body><h1>گزارش</h1></body></html>"
const seeded = seedTurnOutputs([
{
id: '1',
type: 'file',
name: 'best.xlsx',
content: { url: '/files/abc_best.xlsx', name: 'best.xlsx' },
},
{ id: '2', type: 'html', name: 'گزارش', content: html },
])
expect(seeded.html).toBe(html)
expect(seeded.outputs.__exports).toHaveLength(1)
expect(seeded.outputs.__report?.[0]).toMatchObject({ output_type: 'html', content: html })
})
it('reads html stored as an object', () => {
const html = "<html dir='rtl' lang='fa'><body><p>گزارش</p></body></html>"
const seeded = seedTurnOutputs([
{ id: '1', type: 'html', name: 'گزارش', content: { html } },
])
expect(seeded.html).toBe(html)
})
it('merges unique download lists', () => {
const merged = mergeDownloadFiles(
[{ url: '/files/a.xlsx', name: 'a.xlsx' }],
@@ -1,6 +1,7 @@
import type { KamidsoArtifact } from '../../../contracts/run'
import type { DownloadableFile } from '../artifacts/FileDownloadCard'
import { parseDownloadableFile } from '../artifacts/FileDownloadCard'
import { extractHtmlFromArtifacts } from '../report/extractReport'
import type { ExecutionOutput } from '../state/runState'
/** Collect downloadable file outputs from a turn's execution steps. */
@@ -67,3 +68,16 @@ export function seedFileOutputs(
})),
}
}
/** Files plus the HTML report, so history hydrate can paint the report box without replaying SSE. */
export function seedTurnOutputs(artifacts: readonly KamidsoArtifact[] | undefined): {
outputs: Record<string, ExecutionOutput[]>
html: string | null
} {
const html = extractHtmlFromArtifacts(artifacts ? [...artifacts] : undefined)
const outputs = { ...seedFileOutputs(artifacts) }
if (html) {
outputs.__report = [{ output_type: 'html', content: html }]
}
return { outputs, html }
}
@@ -202,6 +202,36 @@ describe('runReducer', () => {
expect(state.thinkingByViewId.v1).toBe('در حال ساخت جدول')
})
it('keeps each turn html when history hydrates more than one run', () => {
const first = "<html dir='rtl' lang='fa'><body><h1>گزارش اول</h1></body></html>"
const second = "<html dir='rtl' lang='fa'><body><h1>گزارش دوم</h1></body></html>"
let state = runReducer(initialRunState(), {
type: 'HYDRATE_RUN',
run: {
htmlByViewId: { v1: first },
executionByViewId: {
v1: {
steps: [],
outputs: { __report: [{ output_type: 'html', content: first }] },
activeStepId: null,
stepThoughts: {},
},
},
},
})
state = runReducer(state, {
type: 'HYDRATE_RUN',
run: {
htmlByViewId: { v2: second },
activeViewId: 'v2',
lastEventSequence: 0,
},
})
expect(state.htmlByViewId.v1).toBe(first)
expect(state.htmlByViewId.v2).toBe(second)
expect(state.executionByViewId.v1?.outputs.__report?.[0]?.content).toBe(first)
})
it('stores html artifacts for the chat iframe', () => {
let state = runReducer(initialRunState(), {
type: 'RUN_CREATED',
@@ -219,4 +249,61 @@ describe('runReducer', () => {
})
expect(state.htmlByViewId.v1).toBe(html)
})
it('streams a sub-agent without finishing the parent dispatch step', () => {
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: 'lead-dispatch', title: 'dispatch_parallel_tasks', action: 'dispatch_parallel_tasks' },
})
state = runReducer(state, {
type: 'PROCESS_EVENT',
viewId: 'v1',
payload: { type: 'agent.start', agent_id: 'sub-1', agent_name: 'صفحه ۲ معاملات' },
})
state = runReducer(state, {
type: 'PROCESS_EVENT',
viewId: 'v1',
payload: {
type: 'agent.thinking',
agent_id: 'sub-1',
agent_name: 'صفحه ۲ معاملات',
content: 'اتصال دیدار را با connector_call می‌خوانم',
},
})
state = runReducer(state, {
type: 'PROCESS_EVENT',
viewId: 'v1',
payload: {
type: 'agent.step',
agent_id: 'sub-1',
round: 2,
action: 'connector_call',
intention: 'دریافت صفحه ۲',
status: 'running',
},
})
const steps = state.executionByViewId.v1?.steps ?? []
const parent = steps.find((step) => step.id === 'lead-dispatch')
expect(parent?.status).toBe('running')
expect(steps.some((step) => step.id === 'subagent:sub-1' && step.status === 'running')).toBe(true)
expect(steps.some((step) => step.id === 'subagent:sub-1:r2' && step.action === 'connector_call')).toBe(true)
expect(state.thinkingByViewId.v1).toContain('connector_call')
state = runReducer(state, {
type: 'PROCESS_EVENT',
viewId: 'v1',
payload: { type: 'agent.done', agent_id: 'sub-1', status: 'done' },
})
const finished = state.executionByViewId.v1?.steps ?? []
expect(finished.find((step) => step.id === 'lead-dispatch')?.status).toBe('running')
expect(finished.find((step) => step.id === 'subagent:sub-1')?.status).toBe('completed')
expect(finished.find((step) => step.id === 'subagent:sub-1:r2')?.status).toBe('completed')
})
})
@@ -1,4 +1,5 @@
import type { KamidsoArtifact, PlanTask } from '../../../contracts/run'
import type { ExecutionStep } from '../types/chat'
import { emptyExecution, initialRunState, type ExecutionState, type RunState } from '../state/runState'
import { normalizeKamidsoEvent } from './normalizeEvent'
@@ -228,6 +229,32 @@ function applyEvent(
return { ...next, status: 'completed', loading: false, plan: [], question: null }
}
if (type.startsWith('agent.')) {
const exec = next.executionByViewId[viewId] ?? emptyExecution()
const updatedExec = applySubagentEvent(exec, event)
if (updatedExec !== exec) {
next = {
...next,
executionByViewId: { ...next.executionByViewId, [viewId]: updatedExec },
steps: updatedExec.steps,
}
}
if (type === 'agent.thinking') {
const content = String(event.content ?? '').trim()
const name = String(event.agent_name ?? '').trim()
if (content) {
next = {
...next,
thinkingByViewId: {
...next.thinkingByViewId,
[viewId]: name ? `${name}\n${content}` : content,
},
}
}
}
return next
}
const exec = next.executionByViewId[viewId] ?? emptyExecution()
const updatedExec = applyExecutionEvent(exec, event)
if (updatedExec !== exec) {
@@ -293,6 +320,93 @@ function finalizeExecution(
}
}
function subagentPrefix(agentId: string) {
return `subagent:${agentId}`
}
function isSubagentStep(id: string, agentId: string) {
const prefix = subagentPrefix(agentId)
return id === prefix || id.startsWith(`${prefix}:`)
}
function recentThought(text: string, max = 800) {
const trimmed = text.trim()
if (trimmed.length <= max) return trimmed
return trimmed.slice(trimmed.length - max).trimStart()
}
function applySubagentEvent(exec: ExecutionState, event: Record<string, unknown>): ExecutionState {
const type = String(event.type ?? '')
const agentId = String(event.agent_id ?? '')
if (!agentId) return exec
const name = String(event.agent_name ?? event.title ?? 'زیرعامل')
const headerId = subagentPrefix(agentId)
if (type === 'agent.start') {
if (exec.steps.some((step) => step.id === headerId)) return exec
const step: ExecutionStep = { id: headerId, title: name, status: 'running' }
return { ...exec, steps: [...exec.steps, step], activeStepId: headerId }
}
if (type === 'agent.thinking') {
const content = recentThought(String(event.content ?? ''))
if (!content) return exec
let steps = exec.steps
if (!steps.some((step) => step.id === headerId)) {
steps = [...steps, { id: headerId, title: name, status: 'running' }]
}
const target =
[...steps].reverse().find((step) => isSubagentStep(step.id, agentId) && step.status === 'running') ??
steps.find((step) => step.id === headerId)
if (!target) return exec
steps = steps.map((step) => (step.id === target.id ? { ...step, detail: content } : step))
return {
...exec,
steps,
activeStepId: target.id,
stepThoughts: { ...exec.stepThoughts, [target.id]: content },
}
}
if (type === 'agent.step') {
const round = Number(event.round)
const id = Number.isFinite(round) ? `${headerId}:r${round}` : `${headerId}:s${exec.steps.length}`
const action = String(event.action ?? '')
const title = String(event.intention || event.action_intention || event.title || action || name)
const status = event.status === 'running' ? 'running' : event.status === 'failed' ? 'failed' : 'completed'
let steps = exec.steps
if (!steps.some((step) => step.id === headerId)) {
steps = [...steps, { id: headerId, title: name, status: 'running' as const }]
}
if (status === 'running') {
steps = steps.map((step) =>
step.id !== headerId && isSubagentStep(step.id, agentId) && step.status === 'running' && step.id !== id
? { ...step, status: 'completed' as const }
: step,
)
}
const existing = steps.find((step) => step.id === id)
const step: ExecutionStep = existing
? { ...existing, title, action: action || existing.action, status }
: { id, title, action, status }
steps = existing ? steps.map((item) => (item.id === id ? step : item)) : [...steps, step]
return { ...exec, steps, activeStepId: id }
}
if (type === 'agent.done') {
const failed = event.status === 'failed' || event.status === 'timeout'
return {
...exec,
steps: exec.steps.map((step) => {
if (!isSubagentStep(step.id, agentId) || step.status !== 'running') return step
return { ...step, status: failed ? 'failed' : 'completed' }
}),
}
}
return exec
}
function applyExecutionEvent(exec: ExecutionState, event: Record<string, unknown>): ExecutionState {
const type = String(event.type ?? '')
if (type === 'step.start') {
@@ -10,7 +10,7 @@ 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'
import { seedTurnOutputs, downloadFilesFromArtifacts } from './downloadFiles'
const ACTIVE_RUN_KEY = 'kamidso.activeRunByConv'
const STREAM_TERMINAL_TYPES = new Set([
@@ -70,7 +70,7 @@ type StreamSendParams = {
skillId?: string
reasoningMode?: 'fast' | 'medium' | 'deep'
reportMode?: boolean
resources?: Array<{ id: string; type: 'file' | 'datasource' | 'knowledge'; name?: string }>
resources?: Array<{ id: string; type: 'file' | 'datasource' | 'knowledge' | 'connector'; name?: string }>
humanMsg: ChatMessage
viewMsg: ChatMessage
}
@@ -341,7 +341,7 @@ export function useRunStream() {
}))
const running = [...steps].reverse().find((item) => item.status === 'running')
const artifacts = (run.artifacts ?? []) as KamidsoArtifact[]
const seededOutputs = seedFileOutputs(artifacts)
const { outputs: seededOutputs, html: htmlReport } = seedTurnOutputs(artifacts)
const downloadFiles = downloadFilesFromArtifacts(artifacts)
dispatch({
type: 'HYDRATE_RUN',
@@ -355,6 +355,7 @@ export function useRunStream() {
activeViewId: setActive ? viewId : undefined,
loading: setActive ? live : undefined,
lastEventSequence: setActive ? 0 : undefined,
htmlByViewId: htmlReport ? { [viewId]: htmlReport } : undefined,
executionByViewId: {
[viewId]: {
steps,
@@ -417,7 +418,7 @@ export function useRunStream() {
dispatch({ type: 'SET_LOADING', loading: false })
}
}
} else if (!live && downloadFiles.length === 0) {
} else if (!live && (downloadFiles.length === 0 || !htmlReport)) {
try {
const response = await streamRunEvents(runId, 0)
await readStream(response, viewId)
+3
View File
@@ -37,6 +37,8 @@ export type ChatMessage = {
context: string
order?: number
thinking?: boolean
/** This turn was sent with the report toggle on. */
reportMode?: boolean
stopped?: boolean
canRetry?: boolean
thought?: string
@@ -48,6 +50,7 @@ export type ChatMessage = {
attachedDatabases?: readonly { id?: string; db_name: string; db_type: string }[]
attachedSkill?: { name: string; id: string }
attachedKnowledge?: { name: string; id: string }
attachedConnectors?: readonly { id: string; name: string }[]
taskPlan?: TaskItem[]
citations?: AgentCitation[]
}
@@ -27,5 +27,33 @@ describe('ChatMessageList', () => {
expect(screen.getByText('محاسبه مقادیر مشاهده‌شده ارزش افزوده با Python')).toBeInTheDocument()
expect(screen.getByText('تولید گزارش نتیجه با html_interpreter')).toBeInTheDocument()
expect(screen.getByText('1/3')).toBeInTheDocument()
expect(screen.queryByText('در حال ساخت گزارش…')).not.toBeInTheDocument()
})
it('shows the report placeholder only for a report-mode turn', () => {
const rounds = [
{
human: { id: 'h1', role: 'human' as const, context: 'خلاصه بده' },
view: { id: 'v1', role: 'view' as const, context: '', thinking: true, reportMode: true },
},
]
const { rerender } = render(
<ChatMessageList loading activeViewId="v1" rounds={rounds} />,
)
expect(screen.getByText('در حال ساخت گزارش…')).toBeInTheDocument()
rerender(
<ChatMessageList
loading
activeViewId="v1"
rounds={[
{
human: rounds[0].human,
view: { ...rounds[0].view, reportMode: false },
},
]}
/>,
)
expect(screen.queryByText('در حال ساخت گزارش…')).not.toBeInTheDocument()
})
})
@@ -78,7 +78,13 @@ export function ChatMessageList({
view?.thought?.trim() ||
(isActiveTurn ? thinkingByViewId?.[viewId ?? '']?.trim() || undefined : undefined)
const reportLoading = Boolean(
isActiveTurn && loading && !htmlReport && !view?.context?.trim() && !thinkingContent && !view?.stopped,
view?.reportMode &&
isActiveTurn &&
loading &&
!htmlReport &&
!view?.context?.trim() &&
!thinkingContent &&
!view?.stopped,
)
const isWorking = Boolean(
!view?.stopped && (view?.thinking || (isActiveTurn && loading && !view?.context?.trim())),
@@ -102,6 +108,7 @@ export function ChatMessageList({
assistantMessage={view?.context}
attachedFiles={human.attachedFiles}
attachedKnowledge={human.attachedKnowledge}
attachedConnectors={human.attachedConnectors}
attachedDb={human.attachedDb}
hideAttachments={hideTurnAttachments}
steps={steps}
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { BookOpen, ChevronDown, Database, FileSpreadsheet, FileText, Image as ImageIcon, X } from 'lucide-react'
import { BookOpen, ChevronDown, Database, FileSpreadsheet, FileText, Image as ImageIcon, Plug, X } from 'lucide-react'
import { t } from '../../../i18n'
import type { SessionFileSnapshot } from '../session-files/types'
import type { ChatMessage } from '../types/chat'
@@ -80,36 +80,44 @@ function PinChip({
)
}
export type ConversationContextBarProps = {
files?: readonly SessionFileSnapshot[]
knowledge?: ChatMessage['attachedKnowledge']
databases?: readonly NonNullable<ChatMessage['attachedDatabases']>[number][]
connectors?: readonly { id: string; name: string }[]
onPreviewFile?: (file: SessionFileSnapshot) => void
onRemoveFile?: (fileId: string) => void
onRemoveDatabase?: (key: string) => void
onRemoveConnector?: (id: string) => void
onClearKnowledge?: () => void
}
export function ConversationContextBar({
files,
knowledge,
databases = [],
connectors = [],
onPreviewFile,
onRemoveFile,
onRemoveDatabase,
onRemoveConnector,
onClearKnowledge,
}: {
files?: readonly SessionFileSnapshot[]
knowledge?: ChatMessage['attachedKnowledge']
databases?: readonly NonNullable<ChatMessage['attachedDatabases']>[number][]
onPreviewFile?: (file: SessionFileSnapshot) => void
onRemoveFile?: (fileId: string) => void
onRemoveDatabase?: (key: string) => void
onClearKnowledge?: () => void
}) {
}: ConversationContextBarProps) {
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 hasConnectors = connectors.length > 0
const sourceCount = fileList.length + (hasKnowledge ? 1 : 0) + databases.length + connectors.length
const [expanded, setExpanded] = useState(sourceCount <= COLLAPSE_AFTER)
if (!hasFiles && !hasKnowledge && !hasDb) return null
if (!hasFiles && !hasKnowledge && !hasDb && !hasConnectors) return null
const canToggle = sourceCount > 4
const showChips = expanded || !canToggle
const previewNames = [
...(hasKnowledge && knowledge?.name ? [knowledge.name] : []),
...databases.map((item) => item.db_name),
...connectors.map((item) => item.name),
...fileList.map((file) => file.name),
]
const preview = previewNames.slice(0, 3).join('، ')
@@ -159,6 +167,16 @@ export function ConversationContextBar({
}
/>
))}
{connectors.map((item) => (
<PinChip
key={item.id || item.name}
icon={Plug}
label={item.name}
hint={t('assistant.composer.connectorHint')}
tone="bg-sky-50 text-sky-700"
onRemove={onRemoveConnector ? () => onRemoveConnector(item.id) : undefined}
/>
))}
{fileList.map((file) => {
const Icon = fileIcon(file.name)
return (
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { BookOpen, Check, ChevronLeft, Copy, Database, RotateCcw, Sparkles, Square } from 'lucide-react'
import { BookOpen, Check, ChevronLeft, Copy, Database, Plug, 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'
@@ -237,17 +237,20 @@ function TurnAttachments({
files,
knowledge,
database,
connectors = [],
onPreviewFile,
}: {
files?: readonly SessionFileSnapshot[]
knowledge?: ChatMessage['attachedKnowledge']
database?: ChatMessage['attachedDb']
connectors?: readonly { id: string; name: string }[]
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
const hasConnectors = connectors.length > 0
if (!hasFiles && !hasKnowledge && !hasDb && !hasConnectors) return null
return (
<div className="flex flex-wrap gap-2">
{hasKnowledge ? (
@@ -266,6 +269,15 @@ function TurnAttachments({
tone="bg-emerald-50 text-emerald-700"
/>
) : null}
{connectors.map((item) => (
<ResourceChip
key={item.id || item.name}
icon={Plug}
label={item.name}
hint={t('assistant.composer.connectorHint')}
tone="bg-sky-50 text-sky-700"
/>
))}
{hasFiles ? <AttachmentMessageCards files={files!} onPreview={onPreviewFile} /> : null}
</div>
)
@@ -276,6 +288,7 @@ export function SessionTurn({
assistantMessage,
attachedFiles,
attachedKnowledge,
attachedConnectors = [],
attachedDb,
steps = [],
taskPlan,
@@ -295,6 +308,7 @@ export function SessionTurn({
assistantMessage?: string
attachedFiles?: readonly SessionFileSnapshot[]
attachedKnowledge?: ChatMessage['attachedKnowledge']
attachedConnectors?: readonly { id: string; name: string }[]
attachedDb?: ChatMessage['attachedDb']
steps?: ExecutionStep[]
taskPlan?: TaskItem[]
@@ -321,7 +335,8 @@ export function SessionTurn({
)
const stillWorkingAfterReport = isWorking && Boolean(htmlReport) && !hasAnswer
const hasAttachments =
!hideAttachments && Boolean(attachedFiles?.length || attachedKnowledge?.name || attachedDb?.db_name)
!hideAttachments &&
Boolean(attachedFiles?.length || attachedKnowledge?.name || attachedDb?.db_name || attachedConnectors.length)
const hasUser = Boolean(question || hasAttachments)
const hasDownloads = Boolean(downloadFiles?.length)
const showAssistant =
@@ -348,6 +363,7 @@ export function SessionTurn({
files={attachedFiles}
knowledge={attachedKnowledge}
database={attachedDb}
connectors={attachedConnectors}
onPreviewFile={onPreviewFile}
/>
</div>
@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Modal } from '../../components/Modal'
import { Button } from '../../components/Button'
import { LabeledInput } from '../../components/ui/Field'
import {
createConnector,
testConnectorCredentials,
testSavedConnector,
updateConnector,
type ConnectorInstance,
type ConnectorProvider,
} from './api'
export function ConnectDidarModal({
open,
provider,
existing,
onClose,
onSaved,
}: {
open: boolean
provider: ConnectorProvider | null
existing?: ConnectorInstance | null
onClose: () => void
onSaved: () => void
}) {
const [name, setName] = useState('')
const [apiKey, setApiKey] = useState('')
const [busy, setBusy] = useState(false)
const [testing, setTesting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [okMessage, setOkMessage] = useState<string | null>(null)
useEffect(() => {
if (!open) return
setName(existing?.display_name || '')
setApiKey('')
setBusy(false)
setTesting(false)
setError(null)
setOkMessage(null)
}, [open, existing])
const title = existing ? 'ویرایش اتصال' : provider?.name || 'دیدار CRM'
const keyLabel = provider?.auth_fields[0]?.label || 'کلید API'
async function onTest() {
if (!provider) return
setError(null)
setOkMessage(null)
setTesting(true)
try {
const result = existing && !apiKey.trim()
? await testSavedConnector(existing.id)
: await testConnectorCredentials({
connector_type: provider.id,
credentials: { api_key: apiKey.trim() },
})
if (result.success) setOkMessage(result.message || 'اتصال برقرار شد.')
else setError(result.message || 'ارتباط با دیدار برقرار نشد.')
} catch (err) {
setError(err instanceof Error ? err.message : 'ارتباط با دیدار برقرار نشد.')
} finally {
setTesting(false)
}
}
async function onConnect() {
if (!provider) return
const displayName = name.trim()
if (!displayName) {
setError('نام اتصال را وارد کنید.')
return
}
if (!existing && !apiKey.trim()) {
setError('کلید API را وارد کنید.')
return
}
setBusy(true)
setError(null)
try {
if (existing) {
await updateConnector(existing.id, {
display_name: displayName,
credentials: apiKey.trim() ? { api_key: apiKey.trim() } : undefined,
})
} else {
await createConnector({
connector_type: provider.id,
display_name: displayName,
credentials: { api_key: apiKey.trim() },
})
}
onSaved()
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'ارتباط با دیدار برقرار نشد.')
} finally {
setBusy(false)
}
}
return (
<Modal
open={open}
title={title}
description={existing ? 'کلید ذخیره‌شده نمایش داده نمی‌شود. برای جایگزینی، کلید جدید را وارد کنید.' : provider?.description}
onClose={onClose}
footer={
<div className="flex flex-wrap justify-end gap-2">
<Button variant="secondary" onClick={() => void onTest()} disabled={testing || busy}>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
آزمایش اتصال
</Button>
<Button onClick={() => void onConnect()} disabled={busy || testing}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
{existing ? 'ذخیره' : 'اتصال'}
</Button>
</div>
}
>
<div className="space-y-3">
<LabeledInput
label="نام اتصال"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="دیدار فروش"
required
/>
<LabeledInput
label={keyLabel}
type="password"
value={apiKey}
autoComplete="new-password"
onChange={(event) => {
setApiKey(event.target.value)
setOkMessage(null)
}}
placeholder={existing ? '••••••••••' : ''}
hint={existing ? 'خالی بگذارید تا کلید فعلی بماند.' : undefined}
required={!existing}
/>
{okMessage ? <p className="text-sm text-emerald-700">{okMessage}</p> : null}
{error ? <p className="text-sm text-danger">{error}</p> : null}
</div>
</Modal>
)
}
@@ -0,0 +1,54 @@
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ConnectorsPage } from './ConnectorsPage'
vi.mock('./api', () => ({
listConnectorProviders: vi.fn(),
listConnectors: vi.fn(),
testSavedConnector: vi.fn(),
deleteConnector: vi.fn(),
}))
import { listConnectorProviders, listConnectors } from './api'
describe('ConnectorsPage', () => {
beforeEach(() => {
vi.mocked(listConnectorProviders).mockResolvedValue([
{
id: 'didar',
name: 'دیدار CRM',
description: 'اتصال مستقیم به مشتریان، معاملات و فعالیت‌ها',
category: 'crm',
kind: 'native',
auth_fields: [{ name: 'api_key', label: 'کلید API', type: 'password', required: true }],
},
])
vi.mocked(listConnectors).mockResolvedValue([
{
id: 'conn_1',
connector_id: 'conn_1',
connector_type: 'didar',
connector_kind: 'native',
display_name: 'دیدار فروش',
status: 'active',
has_credentials: true,
last_tested_at: new Date().toISOString(),
last_test_success: true,
},
])
})
it('shows Didar as an available app and lists the saved connection without a key', async () => {
render(
<MemoryRouter>
<ConnectorsPage />
</MemoryRouter>,
)
expect(await screen.findByText('دیدار فروش')).toBeInTheDocument()
expect(screen.getAllByText('دیدار CRM').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: 'اتصال' })).toBeInTheDocument()
expect(screen.queryByDisplayValue(/api_key|secret/i)).not.toBeInTheDocument()
})
})
+264
View File
@@ -0,0 +1,264 @@
import { useCallback, useEffect, useState } from 'react'
import { Cable, RefreshCw } from 'lucide-react'
import { Button } from '../../components/Button'
import { PageHeader } from '../../components/ui/PageHeader'
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
import { Badge } from '../../components/ui/StatusBadge'
import { ConnectDidarModal } from './ConnectDidarModal'
import { DidarMark } from './DidarMark'
import {
deleteConnector,
listConnectorProviders,
listConnectors,
testSavedConnector,
type ConnectorInstance,
type ConnectorProvider,
} from './api'
function testedLabel(iso?: string | null) {
if (!iso) return 'هنوز آزمایش نشده'
const then = Date.parse(iso)
if (Number.isNaN(then)) return 'هنوز آزمایش نشده'
const minutes = Math.max(0, Math.round((Date.now() - then) / 60000))
if (minutes < 1) return 'لحظاتی پیش'
if (minutes < 60) return `${minutes} دقیقه پیش`
const hours = Math.round(minutes / 60)
if (hours < 24) return `${hours} ساعت پیش`
return `${Math.round(hours / 24)} روز پیش`
}
function statusLabel(item: ConnectorInstance) {
if (item.status === 'error' || item.last_test_success === false) return 'نیاز به بررسی'
if (item.status === 'disabled') return 'غیرفعال'
return 'متصل'
}
function AppMark({ id, name }: { id: string; name: string }) {
if (id === 'didar') return <DidarMark />
const letter = name.trim().slice(0, 1) || 'ا'
return (
<span className="inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-violet-50 text-lg font-bold text-violet-700">
{letter}
</span>
)
}
export function ConnectorsPage() {
const [providers, setProviders] = useState<ConnectorProvider[]>([])
const [items, setItems] = useState<ConnectorInstance[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [connecting, setConnecting] = useState<ConnectorProvider | null>(null)
const [editing, setEditing] = useState<ConnectorInstance | null>(null)
const [removing, setRemoving] = useState<ConnectorInstance | null>(null)
const [busyId, setBusyId] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [nextProviders, nextItems] = await Promise.all([
listConnectorProviders(),
listConnectors(),
])
setProviders(nextProviders)
setItems(nextItems)
} catch (err) {
setError(err instanceof Error ? err.message : 'بارگذاری اتصال‌ها ناموفق بود.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
async function onTest(item: ConnectorInstance) {
setBusyId(item.id)
setNotice(null)
setError(null)
try {
const result = await testSavedConnector(item.id)
setNotice(result.success ? `${item.display_name}: ${result.message}` : result.message)
if (!result.success) setError(result.message)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'ارتباط با دیدار برقرار نشد.')
} finally {
setBusyId(null)
}
}
async function onDelete() {
if (!removing) return
setBusyId(removing.id)
try {
await deleteConnector(removing.id)
setRemoving(null)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'حذف اتصال ناموفق بود.')
} finally {
setBusyId(null)
}
}
const editingProvider =
providers.find((item) => item.id === editing?.connector_type) || providers[0] || null
const showErp = !providers.some((item) => item.id === 'erp' || item.category === 'erp')
return (
<>
<PageHeader
icon={Cable}
title="اتصالات"
subtitle="نرم‌افزارهایی مثل دیدار را وصل کنید. کلید دسترسی در فهرست دیده نمی‌شود."
action={
<button
type="button"
onClick={() => void load()}
className="rounded-xl border border-line bg-panel p-2 text-ink-muted shadow-card hover:text-accent"
aria-label="تازه‌سازی"
>
<RefreshCw className="h-4 w-4" />
</button>
}
/>
{error ? (
<p className="mb-4 rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger" role="alert">
{error}
</p>
) : null}
{notice && !error ? (
<p className="mb-4 rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{notice}</p>
) : null}
<section className="mb-8">
<h2 className="mb-3 text-sm font-semibold text-ink">نرم‌افزارهای آماده</h2>
<div className="grid gap-3 md:grid-cols-2">
{providers.map((provider) => {
const connected = items.filter((item) => item.connector_type === provider.id).length
return (
<article
key={provider.id}
className="flex flex-col rounded-3xl border border-line bg-panel p-4 shadow-card"
>
<div className="flex items-start gap-3">
<AppMark id={provider.id} name={provider.name} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold text-ink">{provider.name}</h3>
{connected ? <Badge tone="success">{connected} متصل</Badge> : <Badge>آماده اتصال</Badge>}
</div>
<p className="mt-0.5 text-[11px] uppercase tracking-wide text-ink-subtle">{provider.category}</p>
<p className="mt-2 text-sm leading-6 text-ink-muted">{provider.description}</p>
</div>
</div>
<div className="mt-4">
<Button size="sm" onClick={() => setConnecting(provider)}>
اتصال
</Button>
</div>
</article>
)
})}
{showErp ? (
<article className="flex flex-col rounded-3xl border border-dashed border-line bg-[linear-gradient(180deg,#fbfcfe,#ffffff)] p-4">
<div className="flex items-start gap-3">
<span className="inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-surface-subtle text-lg font-bold text-ink-muted">
E
</span>
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-ink">ERP</h3>
<Badge>به‌زودی</Badge>
</div>
<p className="mt-2 text-sm leading-6 text-ink-muted">
اتصال سامانه‌های مالی و عملیاتی کنار دیدار، در همین فهرست قرار می‌گیرد.
</p>
</div>
</div>
</article>
) : null}
{!loading && providers.length === 0 && !showErp ? (
<p className="text-sm text-ink-muted">اتصال آماده‌ای برای این محیط فعال نیست.</p>
) : null}
</div>
</section>
<section>
<h2 className="mb-3 text-sm font-semibold text-ink">اتصال‌های برقرارشده</h2>
{loading ? <p className="text-sm text-ink-muted">در حال بارگذاری…</p> : null}
{!loading && items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-line px-4 py-10 text-center text-sm text-ink-muted">
هنوز نرم‌افزاری وصل نشده است.
</div>
) : (
<ul className="overflow-hidden rounded-2xl border border-line bg-panel shadow-card">
{items.map((item) => {
const provider = providers.find((entry) => entry.id === item.connector_type)
const name = provider?.name || item.connector_type
return (
<li key={item.id} className="flex flex-wrap items-center gap-3 border-b border-line px-4 py-3 last:border-b-0">
<AppMark id={item.connector_type} name={name} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-ink">{item.display_name}</p>
<p className="mt-0.5 text-xs text-ink-muted">{name}</p>
<p className="mt-1 text-[12px] text-ink">
<span
className={`me-1 inline-block h-2 w-2 rounded-full ${
item.status === 'error' || item.last_test_success === false
? 'bg-danger'
: item.status === 'disabled'
? 'bg-ink-muted'
: 'bg-emerald-500'
}`}
/>
{statusLabel(item)}
<span className="text-ink-muted"> · آخرین آزمایش: {testedLabel(item.last_tested_at)}</span>
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="secondary" disabled={busyId === item.id} onClick={() => void onTest(item)}>
آزمایش
</Button>
<Button size="sm" variant="secondary" onClick={() => setEditing(item)}>
ویرایش
</Button>
<Button size="sm" variant="danger" onClick={() => setRemoving(item)}>
حذف
</Button>
</div>
</li>
)
})}
</ul>
)}
</section>
<ConnectDidarModal
open={Boolean(connecting || editing)}
provider={connecting || editingProvider}
existing={editing}
onClose={() => {
setConnecting(null)
setEditing(null)
}}
onSaved={() => void load()}
/>
<ConfirmDialog
open={Boolean(removing)}
tone="danger"
title="حذف اتصال"
message={removing ? `«${removing.display_name}» از گفتگوهای جدید حذف می‌شود. گفتگوهای قبلی نام آن را نگه می‌دارند.` : ''}
confirmLabel="حذف"
busy={Boolean(removing && busyId === removing.id)}
onConfirm={() => void onDelete()}
onCancel={() => setRemoving(null)}
/>
</>
)
}
+9
View File
@@ -0,0 +1,9 @@
export function DidarMark({ className = 'h-14 w-14' }: { className?: string }) {
return (
<span
className={`inline-flex shrink-0 items-center justify-center rounded-2xl bg-white ring-1 ring-inset ring-line ${className}`}
>
<img src="/brands/didar.png" alt="دیدار" className="h-[58%] w-[58%] object-contain" />
</span>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { apiFetchEnvelope } from '../../services/api'
export type ConnectorAuthField = {
name: string
label: string
type: string
required: boolean
}
export type ConnectorProvider = {
id: string
name: string
description: string
category: string
kind: 'native' | 'mcp'
auth_fields: ConnectorAuthField[]
}
export type ConnectorInstance = {
id: string
connector_id: string
connector_type: string
connector_kind: 'native' | 'mcp'
display_name: string
status: 'active' | 'disabled' | 'error' | 'deleted' | string
has_credentials: boolean
last_tested_at?: string | null
last_test_success?: boolean | null
}
export type ConnectorTestResult = {
success: boolean
message: string
status_code?: number
}
function asList<T>(data: unknown): T[] {
if (Array.isArray(data)) return data as T[]
return []
}
export async function listConnectorProviders(): Promise<ConnectorProvider[]> {
const data = await apiFetchEnvelope<unknown>('/connectors/providers')
return asList<ConnectorProvider>(data)
}
export async function listConnectors(): Promise<ConnectorInstance[]> {
const data = await apiFetchEnvelope<unknown>('/connectors')
return asList<ConnectorInstance>(data).filter((item) => item.status !== 'deleted')
}
export async function testConnectorCredentials(body: {
connector_type: string
credentials: Record<string, string>
}): Promise<ConnectorTestResult> {
return apiFetchEnvelope<ConnectorTestResult>('/connectors/test', {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function createConnector(body: {
connector_type: string
display_name: string
credentials: Record<string, string>
}): Promise<ConnectorInstance> {
return apiFetchEnvelope<ConnectorInstance>('/connectors', {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function updateConnector(
connectorId: string,
body: { display_name?: string; credentials?: Record<string, string> },
): Promise<ConnectorInstance> {
return apiFetchEnvelope<ConnectorInstance>(`/connectors/${encodeURIComponent(connectorId)}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
export async function deleteConnector(connectorId: string): Promise<void> {
await apiFetchEnvelope(`/connectors/${encodeURIComponent(connectorId)}`, { method: 'DELETE' })
}
export async function testSavedConnector(connectorId: string): Promise<ConnectorTestResult> {
return apiFetchEnvelope<ConnectorTestResult>(`/connectors/${encodeURIComponent(connectorId)}/test`, {
method: 'POST',
})
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { Database, RefreshCw, Trash2 } from 'lucide-react'
import { PageHeader } from '../../components/ui/PageHeader'
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
import { Badge } from '../../components/ui/StatusBadge'
import { TableSkeleton } from '../../components/ui/Skeleton'
import { ConnectEngineModal } from '../databases/ConnectEngineModal'
import { EngineMark } from '../databases/EngineMark'
import { ENGINES, engineById, type EngineId } from '../databases/engines'
import { deleteDatasource, listDatasourceRecords, type DatasourceRecord } from '../databases/api'
import { t } from '../../i18n'
import { useAuth } from '../auth/AuthContext'
const GROUPS: Array<{ labelKey: string; ids: EngineId[] }> = [
{ labelKey: 'dataHub.groupSql', ids: ['mysql', 'postgresql', 'oracle', 'mssql'] },
{ labelKey: 'dataHub.groupOther', ids: ['sqlite', 'neo4j', 'mongodb'] },
]
export function ConnectionsPage() {
const { user } = useAuth()
const canManage = user?.role !== 'user'
const [items, setItems] = useState<DatasourceRecord[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [deletingConnId, setDeletingConnId] = useState<string | null>(null)
const [connectEngine, setConnectEngine] = useState<EngineId | null>(null)
const [editing, setEditing] = useState<DatasourceRecord | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
const ordered = useMemo(
() => items.slice().sort((a, b) => a.name.localeCompare(b.name, 'fa')),
[items],
)
async function load() {
setLoading(true)
setError(null)
try {
setItems(await listDatasourceRecords())
} catch (err) {
setError(err instanceof Error ? err.message : 'بارگذاری اتصال‌ها ناموفق بود')
setItems([])
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
useEffect(() => {
const requested = searchParams.get('engine')
if (!canManage || !requested || !ENGINES.some((engine) => engine.id === requested)) return
setEditing(null)
setConnectEngine(requested as EngineId)
const next = new URLSearchParams(searchParams)
next.delete('engine')
setSearchParams(next, { replace: true })
}, [canManage, searchParams, setSearchParams])
async function confirmDeleteConnection() {
if (!deletingConnId) return
const id = deletingConnId
setDeletingConnId(null)
try {
await deleteDatasource(id)
setItems((current) => current.filter((item) => item.id !== id))
} catch (err) {
setError(err instanceof Error ? err.message : t('databases.deleteFailed'))
}
}
function connectionMeta(item: DatasourceRecord) {
const engine = engineById(item.type)
const host = item.host || String(item.config?.host || '')
const database = item.database || String(item.config?.database || item.config?.path || '')
if (engine.id === 'sqlite') return database || t('databases.kindFile')
return [host, database].filter(Boolean).join(' · ')
}
function openEngine(id: EngineId) {
setEditing(null)
setConnectEngine(id)
}
return (
<>
<PageHeader
icon={Database}
title={t('dataHub.connectionsTitle')}
subtitle={t('dataHub.connectionsSubtitle')}
meta={loading ? null : <Badge>{t('dataHub.connectionCount').replace('{count}', String(items.length))}</Badge>}
action={
<button
type="button"
onClick={() => void load()}
className="rounded-xl border border-line bg-panel p-2 text-ink-muted shadow-card hover:text-accent"
aria-label={t('databases.refresh')}
>
<RefreshCw className="h-4 w-4" />
</button>
}
/>
{error ? (
<p className="mb-4 rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger" role="alert">
{error}
</p>
) : null}
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_300px]">
<section className={`min-w-0 ${!loading && ordered.length === 0 ? 'order-2 lg:order-1' : ''}`}>
<h2 className="mb-3 text-sm font-semibold text-ink">{t('dataHub.savedTitle')}</h2>
{loading ? (
<TableSkeleton rows={4} />
) : ordered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-line bg-panel px-6 py-14 text-center shadow-card">
<span className="mx-auto inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-accent-soft text-accent">
<Cable className="h-5 w-5" />
</span>
<h3 className="mt-4 text-base font-semibold text-ink">{t('dataHub.savedEmptyTitle')}</h3>
<p className="mx-auto mt-1.5 max-w-sm text-sm leading-6 text-ink-muted">{t('dataHub.savedEmptyBody')}</p>
</div>
) : (
<ul className="overflow-hidden rounded-2xl border border-line bg-panel shadow-card">
{ordered.map((item) => {
const engine = engineById(item.type)
return (
<li key={item.id} className="border-b border-line last:border-b-0">
<div
className={`flex items-center gap-3 px-4 py-3 ${
canManage ? 'cursor-pointer hover:bg-surface-subtle' : ''
}`}
onClick={() => canManage && setEditing(item)}
onKeyDown={(event) => {
if (!canManage) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setEditing(item)
}
}}
role={canManage ? 'button' : undefined}
tabIndex={canManage ? 0 : undefined}
>
<EngineMark id={engine.id} className="h-10 w-10" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold text-ink">{item.name}</h3>
<Badge tone="accent">{engine.label}</Badge>
</div>
<p className="mt-0.5 truncate text-[12px] text-ink-muted" dir="ltr">
{connectionMeta(item)}
</p>
</div>
{canManage ? (
<button
type="button"
className="rounded-lg p-1.5 text-ink-muted transition hover:bg-danger-soft hover:text-danger"
aria-label={t('databases.deleteTitle')}
onClick={(event) => {
event.stopPropagation()
setDeletingConnId(item.id)
}}
>
<Trash2 className="h-4 w-4" />
</button>
) : null}
</div>
</li>
)
})}
</ul>
)}
{ordered.length ? (
<p className="mt-4 text-sm">
<Link to="/data/map" className="font-medium text-accent hover:underline">
{t('dataHub.openMap')}
</Link>
</p>
) : null}
</section>
<aside className={`lg:sticky lg:top-4 ${!loading && ordered.length === 0 ? 'order-1 lg:order-2' : ''}`}>
<div className="rounded-2xl border border-line bg-panel p-3 shadow-card">
<h2 className="px-2 pt-1 text-sm font-semibold text-ink">{t('dataHub.addEngineTitle')}</h2>
<p className="mt-1 px-2 text-[12px] leading-5 text-ink-muted">{t('dataHub.addEngineHint')}</p>
{!canManage ? <p className="mt-2 px-2 text-[12px] leading-5 text-ink-muted">{t('dataHub.manageOnly')}</p> : null}
{GROUPS.map((group) => (
<div key={group.labelKey} className="mt-3">
<p className="px-2 pb-1 text-[11px] font-semibold text-ink-subtle">{t(group.labelKey)}</p>
<div className="space-y-0.5">
{group.ids.map((id) => {
const engine = ENGINES.find((item) => item.id === id) || engineById(id)
const count = items.filter((item) => item.type === engine.id).length
return (
<button
key={engine.id}
type="button"
disabled={!canManage}
onClick={() => openEngine(engine.id)}
className="flex w-full items-center gap-2.5 rounded-xl px-2 py-2 text-right transition hover:bg-surface-subtle disabled:cursor-not-allowed disabled:opacity-60"
>
<EngineMark id={engine.id} className="h-9 w-9" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate text-[13px] font-semibold text-ink">{engine.label}</span>
{count ? <Badge tone="accent">{count}</Badge> : null}
</span>
<span className="mt-0.5 block truncate text-[11px] text-ink-muted">{t(engine.blurbKey)}</span>
</span>
</button>
)
})}
</div>
</div>
))}
</div>
</aside>
</div>
<ConnectEngineModal
open={Boolean(connectEngine) || Boolean(editing)}
engineId={connectEngine || (editing?.type as EngineId) || 'mysql'}
existing={editing}
onClose={() => {
setConnectEngine(null)
setEditing(null)
}}
onSaved={() => void load()}
/>
<ConfirmDialog
open={Boolean(deletingConnId)}
tone="danger"
title={t('databases.deleteTitle')}
message={t('databases.deleteMessage')}
confirmLabel={t('databases.delete')}
onConfirm={() => void confirmDeleteConnection()}
onCancel={() => setDeletingConnId(null)}
/>
</>
)
}
+357
View File
@@ -0,0 +1,357 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { Braces, Cable, Database, FileSpreadsheet, LayoutDashboard, Network, Table2 } from 'lucide-react'
import { PageHeader } from '../../components/ui/PageHeader'
import { CardGridSkeleton } from '../../components/ui/Skeleton'
import { listWorkspaceFiles, type KamidsoFile } from '../assistant/session-files'
import { isDataFileKind, isDataFilename } from '../assistant/session-files/fileKind'
import { listConnectors, type ConnectorInstance } from '../connectors/api'
import { DidarMark } from '../connectors/DidarMark'
import { listDatasourceRecords, type DatasourceRecord } from '../databases/api'
import { EngineMark } from '../databases/EngineMark'
import { ENGINES, engineById, type EngineId } from '../databases/engines'
import { listEdges, listMaps } from '../data-map/api'
import type { DataEdge, SourceMap } from '../data-map/types'
import { t } from '../../i18n'
const FILE_SUGGESTIONS = [
{ id: 'excel', label: 'Excel', hintKey: 'dataHub.hintExcel', icon: FileSpreadsheet, wrap: 'bg-emerald-50 text-emerald-700' },
{ id: 'csv', label: 'CSV', hintKey: 'dataHub.hintCsv', icon: Table2, wrap: 'bg-sky-50 text-sky-700' },
{ id: 'json', label: 'JSON', hintKey: 'dataHub.hintJson', icon: Braces, wrap: 'bg-amber-50 text-amber-800' },
] as const
const DATABASE_SUGGESTIONS: EngineId[] = ['mysql', 'postgresql', 'mssql', 'oracle']
const STEPS = [
{ icon: FileSpreadsheet, title: 'dataHub.stepFiles', hint: 'dataHub.stepFilesHint', to: '/data/files', wrap: 'bg-emerald-50 text-emerald-700' },
{ icon: Database, title: 'dataHub.stepDatabases', hint: 'dataHub.stepDatabasesHint', to: '/data/databases', wrap: 'bg-sky-50 text-sky-800' },
{ icon: Cable, title: 'dataHub.stepApps', hint: 'dataHub.stepAppsHint', to: '/data/connectors', wrap: 'bg-violet-50 text-violet-700' },
{ icon: Network, title: 'dataHub.stepMap', hint: 'dataHub.stepMapHint', to: '/data/map', wrap: 'bg-accent-soft text-accent' },
] as const
function dataFilesOf(files: KamidsoFile[]) {
return files.filter(
(file) => isDataFileKind(file.kind) || isDataFilename(file.original_name || file.name || ''),
)
}
export function DataDashboardPage() {
const [files, setFiles] = useState<KamidsoFile[]>([])
const [databases, setDatabases] = useState<DatasourceRecord[]>([])
const [apps, setApps] = useState<ConnectorInstance[]>([])
const [maps, setMaps] = useState<SourceMap[]>([])
const [edges, setEdges] = useState<DataEdge[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
const [fileResult, databaseResult, appResult, mapResult, edgeResult] = await Promise.allSettled([
listWorkspaceFiles(),
listDatasourceRecords(),
listConnectors(),
listMaps(),
listEdges(),
])
if (cancelled) return
setFiles(fileResult.status === 'fulfilled' ? dataFilesOf(fileResult.value) : [])
setDatabases(databaseResult.status === 'fulfilled' ? databaseResult.value : [])
setApps(appResult.status === 'fulfilled' ? appResult.value : [])
setMaps(mapResult.status === 'fulfilled' ? mapResult.value : [])
setEdges(edgeResult.status === 'fulfilled' ? edgeResult.value : [])
setLoading(false)
}
void load()
return () => {
cancelled = true
}
}, [])
const readyMaps = maps.filter((item) => item.status === 'ready').length
const confirmedEdges = edges.filter((item) => item.status === 'confirmed').length
const mapSources = files.length + databases.length
const sourceTotal = mapSources + apps.length
const empty = sourceTotal === 0
const mix = useMemo(
() => [
{ key: 'files', value: files.length, className: 'bg-emerald-500', label: t('dataHub.statFiles') },
{ key: 'databases', value: databases.length, className: 'bg-sky-600', label: t('dataHub.statDatabases') },
{ key: 'apps', value: apps.length, className: 'bg-violet-500', label: t('dataHub.statApps') },
],
[apps.length, databases.length, files.length],
)
const mixTotal = sourceTotal || 1
return (
<>
<PageHeader
icon={LayoutDashboard}
title={t('dataHub.dashboardTitle')}
subtitle={t('dataHub.dashboardSubtitle')}
/>
{loading ? <CardGridSkeleton count={4} /> : null}
{!loading && empty ? <EmptyGuide /> : null}
{!loading && !empty ? (
<>
<div className="mb-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label={t('dataHub.statFiles')} value={files.length} to="/data/files" tone="bg-emerald-500" />
<StatCard label={t('dataHub.statDatabases')} value={databases.length} to="/data/databases" tone="bg-sky-600" />
<StatCard label={t('dataHub.statApps')} value={apps.length} to="/data/connectors" tone="bg-violet-500" />
<StatCard label={t('dataHub.statDiscovered')} value={readyMaps} to="/data/map" tone="bg-accent" />
</div>
<section className="mb-5 rounded-2xl border border-line bg-panel p-4 shadow-card">
<div className="mb-3 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-ink">{t('dataHub.mixLabel')}</h2>
<p className="mt-1 text-[12px] text-ink-muted">
{mapSources
? readyMaps < mapSources
? t('dataHub.mapProgress').replace('{ready}', String(readyMaps)).replace('{total}', String(mapSources))
: t('dataHub.mapReady')
: t('dataHub.stepMapHint')}
{confirmedEdges ? ` · ${t('dataHub.confirmedLinks').replace('{count}', String(confirmedEdges))}` : ''}
</p>
</div>
<Link to="/data/map" className="text-sm font-medium text-accent hover:underline">
{t('dataHub.openOrgMap')}
</Link>
</div>
<div className="flex h-3 overflow-hidden rounded-full bg-surface-subtle">
{mix.map((part) =>
part.value ? (
<div
key={part.key}
className={part.className}
style={{ width: `${(part.value / mixTotal) * 100}%` }}
title={part.label}
/>
) : null,
)}
</div>
<div className="mt-3 flex flex-wrap gap-4 text-[12px] text-ink-muted">
{mix.map((part) => (
<span key={part.key} className="inline-flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full ${part.className}`} />
{part.label}
<span className="tabular-nums text-ink">{part.value}</span>
</span>
))}
</div>
</section>
<div className="grid gap-4 lg:grid-cols-3">
<RecentList
title={t('dataHub.recentFiles')}
to="/data/files"
empty={files.length === 0}
rows={files.slice(0, 4).map((file) => ({
id: file.id,
title: file.title?.trim() || file.name,
meta: file.original_name || file.name,
}))}
/>
<RecentList
title={t('dataHub.recentDatabases')}
to="/data/databases"
empty={databases.length === 0}
rows={databases.slice(0, 4).map((item) => ({
id: item.id,
title: item.name,
meta: engineById(item.type).label,
}))}
/>
<RecentList
title={t('dataHub.recentApps')}
to="/data/connectors"
empty={apps.length === 0}
rows={apps.slice(0, 4).map((item) => ({
id: item.id,
title: item.display_name,
meta: item.connector_type,
}))}
/>
</div>
</>
) : null}
</>
)
}
function StatCard({
label,
value,
to,
tone,
}: {
label: string
value: number
to: string
tone: string
}) {
return (
<Link
to={to}
className="rounded-2xl border border-line bg-panel p-4 shadow-card transition hover:-translate-y-0.5 hover:border-line-strong"
>
<span className={`mb-3 block h-1.5 w-10 rounded-full ${tone}`} />
<p className="text-3xl font-semibold tabular-nums tracking-tight text-ink">{value}</p>
<p className="mt-1 text-sm text-ink-muted">{label}</p>
</Link>
)
}
function EmptyGuide() {
return (
<div className="space-y-5">
<section className="overflow-hidden rounded-3xl border border-line bg-panel shadow-card">
<div className="bg-[radial-gradient(circle_at_top_right,rgba(37,99,235,0.12),transparent_42%),linear-gradient(180deg,#f8fbff,#ffffff)] px-5 py-6 md:px-7">
<p className="text-[11px] font-semibold text-accent">{t('dataHub.dashboardEmptyKicker')}</p>
<h2 className="mt-1 text-xl font-bold tracking-tight text-ink">{t('dataHub.dashboardEmptyTitle')}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-ink-muted">{t('dataHub.dashboardEmptyBody')}</p>
<ol className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{STEPS.map((step, index) => {
const Icon = step.icon
return (
<li key={step.to}>
<Link
to={step.to}
className="flex h-full items-start gap-3 rounded-2xl border border-line bg-white/80 p-3 transition hover:border-line-strong hover:shadow-sm"
>
<span className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${step.wrap}`}>
<Icon className="h-4 w-4" />
</span>
<span>
<span className="block text-[11px] text-ink-subtle">{index + 1}</span>
<span className="block text-sm font-semibold text-ink">{t(step.title)}</span>
<span className="mt-0.5 block text-[12px] leading-5 text-ink-muted">{t(step.hint)}</span>
</span>
</Link>
</li>
)
})}
</ol>
</div>
</section>
<section>
<h2 className="mb-3 text-sm font-semibold text-ink">{t('dataHub.suggestFiles')}</h2>
<div className="grid gap-3 sm:grid-cols-3">
{FILE_SUGGESTIONS.map((item) => {
const Icon = item.icon
return (
<Link
key={item.id}
to="/data/files"
className="flex items-center gap-3 rounded-2xl border border-line bg-panel px-4 py-4 shadow-card transition hover:-translate-y-0.5 hover:border-line-strong"
>
<span className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl ${item.wrap}`}>
<Icon className="h-5 w-5" />
</span>
<span>
<span className="block text-sm font-semibold text-ink">{item.label}</span>
<span className="mt-0.5 block text-[12px] text-ink-muted">{t(item.hintKey)}</span>
</span>
</Link>
)
})}
</div>
</section>
<section>
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-ink">{t('dataHub.suggestDatabases')}</h2>
<Link to="/data/databases" className="text-[12px] font-medium text-accent hover:underline">
{t('dataHub.openDatabases')}
</Link>
</div>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{DATABASE_SUGGESTIONS.map((id) => {
const engine = ENGINES.find((item) => item.id === id) || engineById(id)
return (
<Link
key={engine.id}
to={`/data/databases?engine=${engine.id}`}
className="flex items-center gap-3 rounded-2xl border border-line bg-panel px-3 py-3 shadow-card transition hover:-translate-y-0.5 hover:border-line-strong"
>
<EngineMark id={engine.id} className="h-11 w-11" />
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-ink">{engine.label}</span>
<span className="mt-0.5 block truncate text-[11px] text-ink-muted">{t(engine.kindKey)}</span>
</span>
</Link>
)
})}
</div>
</section>
<section className="grid gap-3 lg:grid-cols-[1.4fr_1fr]">
<Link
to="/data/connectors"
className="flex items-center gap-4 rounded-2xl border border-line bg-panel p-4 shadow-card transition hover:-translate-y-0.5 hover:border-line-strong"
>
<DidarMark />
<span>
<span className="block text-sm font-semibold text-ink">{t('dataHub.suggestApps')}</span>
<span className="mt-1 block text-[13px] leading-6 text-ink-muted">{t('dataHub.suggestAppsBody')}</span>
</span>
</Link>
<Link
to="/data/map"
className="flex items-center gap-4 rounded-2xl border border-dashed border-line bg-panel p-4 transition hover:border-line-strong"
>
<span className="inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-accent-soft text-accent">
<Network className="h-6 w-6" />
</span>
<span>
<span className="block text-sm font-semibold text-ink">{t('dataHub.stepMap')}</span>
<span className="mt-1 block text-[13px] leading-6 text-ink-muted">{t('dataHub.stepMapHint')}</span>
</span>
</Link>
</section>
</div>
)
}
function RecentList({
title,
to,
rows,
empty,
}: {
title: string
to: string
rows: Array<{ id: string; title: string; meta: string }>
empty: boolean
}) {
return (
<section className="rounded-2xl border border-line bg-panel p-4 shadow-card">
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ink">{title}</h2>
<Link to={to} className="text-[12px] font-medium text-accent hover:underline">
{empty ? t('dataHub.add') : t('dataHub.view')}
</Link>
</div>
{empty ? (
<Link to={to} className="block rounded-xl border border-dashed border-line px-3 py-6 text-center text-sm text-ink-muted">
{t('dataHub.noneYet')}
</Link>
) : (
<ul className="space-y-2">
{rows.map((row) => (
<li key={row.id} className="rounded-xl bg-surface-subtle px-3 py-2">
<p className="truncate text-sm font-medium text-ink">{row.title}</p>
<p className="truncate text-[11px] text-ink-muted" dir="ltr">
{row.meta}
</p>
</li>
))}
</ul>
)}
</section>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { describe, expect, it } from 'vitest'
import { DataHubLayout } from './DataHubLayout'
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/data" element={<DataHubLayout />}>
<Route index element={<div>dashboard-body</div>} />
<Route path="files" element={<div>files-body</div>} />
<Route path="databases" element={<div>databases-body</div>} />
<Route path="connectors" element={<div>connectors-body</div>} />
<Route path="map" element={<div>map-body</div>} />
</Route>
</Routes>
</MemoryRouter>,
)
}
describe('DataHubLayout', () => {
it('opens on the dashboard, then files, databases, apps, and the organization map', () => {
renderAt('/data')
const tabs = screen.getAllByRole('link')
expect(tabs.map((tab) => tab.textContent)).toEqual(['داشبورد', 'فایل‌ها', 'دیتابیس', 'اتصالات', 'نقشه سازمان'])
expect(tabs[0]).toHaveAttribute('href', '/data')
expect(tabs[1]).toHaveAttribute('href', '/data/files')
expect(tabs[2]).toHaveAttribute('href', '/data/databases')
expect(tabs[3]).toHaveAttribute('href', '/data/connectors')
expect(tabs[4]).toHaveAttribute('href', '/data/map')
expect(screen.getByText('dashboard-body')).toBeInTheDocument()
})
it('keeps the same tabs above the organization map', () => {
renderAt('/data/map')
expect(screen.getByText('map-body')).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'بخش داده' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'نقشه سازمان' })).toHaveAttribute('href', '/data/map')
})
})
+65
View File
@@ -0,0 +1,65 @@
import { Cable, Database, Files, LayoutDashboard, Network } from 'lucide-react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { PageShell } from '../../components/ui/PageShell'
import { t } from '../../i18n'
const TABS = [
{ to: '/data', label: 'dataHub.tabDashboard', icon: LayoutDashboard, end: true },
{ to: '/data/files', label: 'dataHub.tabFiles', icon: Files, end: true },
{ to: '/data/databases', label: 'dataHub.tabDatabases', icon: Database, end: true },
{ to: '/data/connectors', label: 'dataHub.tabApps', icon: Cable, end: true },
{ to: '/data/map', label: 'dataHub.tabMap', icon: Network, end: true },
] as const
function DataTabs({ className = 'mb-6' }: { className?: string }) {
return (
<nav
aria-label={t('dataHub.sectionLabel')}
className={`inline-flex max-w-full gap-1 overflow-x-auto rounded-2xl border border-line bg-panel p-1 shadow-card ${className}`}
>
{TABS.map((tab) => {
const Icon = tab.icon
return (
<NavLink
key={tab.to}
to={tab.to}
end={tab.end}
className={({ isActive }) =>
`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm whitespace-nowrap transition ${
isActive
? 'bg-accent-soft font-medium text-accent'
: 'text-ink-muted hover:bg-surface-subtle hover:text-ink'
}`
}
>
<Icon className="h-4 w-4" />
{t(tab.label)}
</NavLink>
)
})}
</nav>
)
}
export function DataHubLayout() {
const { pathname } = useLocation()
if (pathname.startsWith('/data/map')) {
return (
<>
<div className="sticky top-0 z-20 border-b border-line bg-surface/95 backdrop-blur">
<div className="mx-auto max-w-[1600px] px-4 py-3 md:px-6">
<DataTabs className="mb-0" />
</div>
</div>
<Outlet />
</>
)
}
return (
<PageShell>
<DataTabs />
<Outlet />
</PageShell>
)
}
+380
View File
@@ -0,0 +1,380 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { Braces, FileSpreadsheet, Layers, Search, Table2, Trash2, Upload } from 'lucide-react'
import { Button } from '../../components/Button'
import { Input } from '../../components/ui/Field'
import { PageHeader } from '../../components/ui/PageHeader'
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
import { Badge } from '../../components/ui/StatusBadge'
import { TableSkeleton } from '../../components/ui/Skeleton'
import {
deleteFile,
listWorkspaceFiles,
uploadWorkspaceFile,
type KamidsoFile,
} from '../assistant/session-files'
import { FileTitleModal } from '../assistant/session-files/FileTitleModal'
import {
DATA_FILE_ACCEPT,
isDataFileKind,
isDataFilename,
kindFromFilename,
} from '../assistant/session-files/fileKind'
import { formatBytes } from '../assistant/session-files/attachment-view-model'
import { getLocale, t } from '../../i18n'
type SourceKind = 'excel' | 'csv' | 'json' | 'parquet'
const KIND_META: Record<
SourceKind,
{ icon: typeof FileSpreadsheet; wrap: string; label: string; hintKey?: string }
> = {
excel: { icon: FileSpreadsheet, wrap: 'bg-emerald-50 text-emerald-700', label: 'Excel', hintKey: 'dataHub.hintExcel' },
csv: { icon: Table2, wrap: 'bg-sky-50 text-sky-700', label: 'CSV', hintKey: 'dataHub.hintCsv' },
json: { icon: Braces, wrap: 'bg-amber-50 text-amber-800', label: 'JSON', hintKey: 'dataHub.hintJson' },
parquet: { icon: Layers, wrap: 'bg-slate-100 text-slate-700', label: 'Parquet' },
}
function sourceKind(file: KamidsoFile): SourceKind | null {
const raw = (file.kind || kindFromFilename(file.original_name || file.name || '')).toLowerCase()
if (raw === 'excel' || raw === 'table' || raw === 'spreadsheet') return 'excel'
if (raw === 'csv') return 'csv'
if (raw === 'json') return 'json'
if (raw === 'parquet') return 'parquet'
return null
}
function formatWhen(value?: string) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat(getLocale() === 'en' ? 'en-US' : 'fa-IR', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date)
}
export function SourcesPage() {
const [files, setFiles] = useState<KamidsoFile[]>([])
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pendingUpload, setPendingUpload] = useState<File[] | null>(null)
const [deletingFileId, setDeletingFileId] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false)
const [kindFilter, setKindFilter] = useState<SourceKind | 'all'>('all')
const [query, setQuery] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const dragDepth = useRef(0)
const dataFiles = useMemo(() => {
return files
.filter((file) => isDataFileKind(file.kind) || isDataFilename(file.original_name || file.name || ''))
.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)))
}, [files])
const counts = useMemo(() => {
const next: Record<SourceKind, number> = { excel: 0, csv: 0, json: 0, parquet: 0 }
for (const file of dataFiles) {
const kind = sourceKind(file)
if (kind) next[kind] += 1
}
return next
}, [dataFiles])
const visible = useMemo(() => {
const needle = query.trim().toLowerCase()
return dataFiles.filter((file) => {
const kind = sourceKind(file)
if (kindFilter !== 'all' && kind !== kindFilter) return false
if (!needle) return true
const haystack = `${file.title || ''} ${file.name || ''} ${file.original_name || ''}`.toLowerCase()
return haystack.includes(needle)
})
}, [dataFiles, kindFilter, query])
const formats: SourceKind[] = ['excel', 'csv', 'json']
if (counts.parquet) formats.push('parquet')
async function load() {
setLoading(true)
setError(null)
try {
setFiles(await listWorkspaceFiles())
} catch (err) {
setError(err instanceof Error ? err.message : t('databases.fileUploadFailed'))
setFiles([])
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
function takeFiles(list: FileList | File[]) {
const next = Array.from(list).filter((file) => isDataFilename(file.name))
if (!next.length) {
setError(t('databases.fileTypeError'))
return
}
setError(null)
setPendingUpload(next)
}
async function confirmUpload(nextFiles: File[], titles: string[]) {
setPendingUpload(null)
setUploading(true)
try {
for (let index = 0; index < nextFiles.length; index += 1) {
await uploadWorkspaceFile(nextFiles[index]!, titles[index])
}
await load()
} catch (err) {
setError(err instanceof Error ? err.message : t('databases.fileUploadFailed'))
} finally {
setUploading(false)
}
}
async function confirmDeleteFile() {
if (!deletingFileId) return
const id = deletingFileId
setDeletingFileId(null)
try {
await deleteFile('', id)
setFiles((current) => current.filter((file) => file.id !== id))
} catch (err) {
setError(err instanceof Error ? err.message : t('databases.fileDeleteFailed'))
}
}
function browse() {
fileInputRef.current?.click()
}
return (
<>
<PageHeader
icon={FileSpreadsheet}
title={t('dataHub.sourcesTitle')}
subtitle={t('dataHub.sourcesSubtitle')}
meta={loading ? null : <Badge>{t('dataHub.fileCount').replace('{count}', String(dataFiles.length))}</Badge>}
action={
<Button onClick={browse} disabled={uploading}>
<Upload className="h-4 w-4" />
{t('dataHub.addFile')}
</Button>
}
/>
{error ? (
<p className="mb-4 rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger" role="alert">
{error}
</p>
) : null}
{loading ? (
<TableSkeleton rows={4} />
) : (
<>
{dataFiles.length ? (
<div
className={`mb-4 grid grid-cols-1 gap-2 ${
formats.length > 3 ? 'sm:grid-cols-2 xl:grid-cols-4' : 'sm:grid-cols-3'
}`}
>
{formats.map((kind) => {
const meta = KIND_META[kind]
const Icon = meta.icon
const active = kindFilter === kind
return (
<button
key={kind}
type="button"
onClick={() => setKindFilter(active ? 'all' : kind)}
className={`flex items-center gap-3 rounded-2xl border bg-panel px-3.5 py-3 text-right shadow-card transition hover:border-line-strong ${
active ? 'border-accent ring-2 ring-accent/15' : 'border-line'
}`}
>
<span className={`inline-flex h-10 w-10 items-center justify-center rounded-xl ${meta.wrap}`}>
<Icon className="h-5 w-5" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="text-sm font-semibold text-ink">{meta.label}</span>
<span className="text-lg font-semibold tabular-nums text-ink">{counts[kind]}</span>
</span>
{meta.hintKey ? <span className="mt-0.5 block text-[12px] text-ink-muted">{t(meta.hintKey)}</span> : null}
</span>
</button>
)
})}
</div>
) : null}
<div
onDragEnter={(event) => {
event.preventDefault()
dragDepth.current += 1
setDragOver(true)
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
event.preventDefault()
dragDepth.current -= 1
if (dragDepth.current <= 0) {
dragDepth.current = 0
setDragOver(false)
}
}}
onDrop={(event) => {
event.preventDefault()
dragDepth.current = 0
setDragOver(false)
if (event.dataTransfer.files?.length) takeFiles(event.dataTransfer.files)
}}
className={`mb-5 rounded-2xl border border-dashed transition ${
dragOver ? 'border-accent bg-accent-soft' : 'border-line-strong bg-panel'
} ${dataFiles.length ? 'px-4 py-3' : 'px-6 py-12'}`}
>
{dataFiles.length ? (
<div className="flex flex-wrap items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-accent-soft text-accent">
<Upload className="h-4 w-4" />
</span>
<p className="min-w-0 flex-1 text-sm text-ink-muted">{t('dataHub.dropCompact')}</p>
<Button variant="secondary" onClick={browse} disabled={uploading}>
{t('dataHub.browse')}
</Button>
</div>
) : (
<div className="mx-auto flex max-w-md flex-col items-center text-center">
<div className="mb-4 flex items-center gap-2">
{(['excel', 'csv', 'json'] as const).map((kind) => {
const meta = KIND_META[kind]
const Icon = meta.icon
return (
<span
key={kind}
className={`inline-flex h-12 w-12 items-center justify-center rounded-2xl ${meta.wrap}`}
>
<Icon className="h-5 w-5" />
</span>
)
})}
</div>
<h2 className="text-base font-semibold text-ink">{t('dataHub.dropTitle')}</h2>
<p className="mt-1.5 text-sm leading-6 text-ink-muted">{t('dataHub.dropHint')}</p>
<Button className="mt-4" onClick={browse} disabled={uploading}>
<Upload className="h-4 w-4" />
{t('dataHub.browse')}
</Button>
</div>
)}
</div>
{dataFiles.length ? (
<section className="overflow-hidden rounded-2xl border border-line bg-panel shadow-card">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-line px-4 py-3">
<h2 className="text-sm font-semibold text-ink">
{t('dataHub.fileCount').replace('{count}', String(visible.length))}
</h2>
<div className="w-full sm:w-64">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('dataHub.searchPlaceholder')}
aria-label={t('dataHub.searchPlaceholder')}
leadingIcon={<Search className="h-4 w-4" />}
/>
</div>
</div>
{visible.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-ink-muted">{t('dataHub.noMatches')}</p>
) : (
<ul>
{visible.map((file) => {
const kind = sourceKind(file) || 'excel'
const meta = KIND_META[kind]
const Icon = meta.icon
const title = file.title?.trim() || file.name
return (
<li
key={file.id}
className="flex items-center gap-3 border-b border-line px-4 py-3 last:border-b-0"
>
<span className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${meta.wrap}`}>
<Icon className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold text-ink">{title}</h3>
{file.status === 'processing' ? <Badge tone="accent">{t('dataHub.statusProcessing')}</Badge> : null}
{file.status === 'failed' ? <Badge tone="danger">{t('dataHub.statusFailed')}</Badge> : null}
</div>
<p className="mt-0.5 truncate text-[12px] text-ink-muted" dir="ltr">
{file.original_name || file.name}
</p>
</div>
<div className="hidden shrink-0 text-left text-[12px] text-ink-subtle sm:block">
<p>{formatBytes(file.size_bytes)}</p>
<p className="mt-0.5">{formatWhen(file.created_at)}</p>
</div>
<Badge>{meta.label}</Badge>
<button
type="button"
className="rounded-lg p-1.5 text-ink-muted transition hover:bg-danger-soft hover:text-danger"
aria-label={t('databases.deleteFile')}
onClick={() => setDeletingFileId(file.id)}
>
<Trash2 className="h-4 w-4" />
</button>
</li>
)
})}
</ul>
)}
</section>
) : null}
{dataFiles.length ? (
<p className="mt-4 text-sm">
<Link to="/data/map" className="font-medium text-accent hover:underline">
{t('dataHub.openMap')}
</Link>
</p>
) : null}
</>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept={DATA_FILE_ACCEPT}
className="hidden"
onChange={(event) => {
if (event.target.files?.length) takeFiles(event.target.files)
event.target.value = ''
}}
/>
<FileTitleModal
files={pendingUpload}
onClose={() => setPendingUpload(null)}
onConfirm={(nextFiles, titles) => void confirmUpload(nextFiles, titles)}
/>
<ConfirmDialog
open={Boolean(deletingFileId)}
tone="danger"
title={t('databases.deleteFile')}
message={t('databases.deleteFileMessage')}
confirmLabel={t('databases.delete')}
onConfirm={() => void confirmDeleteFile()}
onCancel={() => setDeletingFileId(null)}
/>
</>
)
}
@@ -41,13 +41,13 @@ const CAPABILITIES: Capability[] = [
icon: FileBarChart,
title: 'فایل داده را تحلیل کنید',
description:
'Excel و CSV را یک‌بار در پایگاه داده بارگذاری کنید و بعداً در گفتگو انتخاب کنید؛ پیش‌نمایش، برنامه اجرا و پاسخ مرحله‌به‌مرحله.',
'Excel، CSV و JSON را یک‌بار در بخش داده بارگذاری کنید و بعداً در گفتگو انتخاب کنید؛ پیش‌نمایش، برنامه اجرا و پاسخ مرحله‌به‌مرحله.',
},
{
icon: LayoutDashboard,
title: 'به پایگاه داده و دانش وصل شوید',
title: 'به داده و دانش وصل شوید',
description:
'اتصال SQL و فایل جدولی در پایگاه داده است؛ PDF و اسناد در دانش. در گفتگو منبع داده یا دانش را انتخاب کنید.',
'فایل، پایگاه داده و اتصال نرم‌افزار در بخش داده است؛ PDF و اسناد در دانش. در گفتگو منبع داده یا دانش را انتخاب کنید.',
},
{
icon: Bell,
+5 -4
View File
@@ -19,6 +19,10 @@ export function SettingsLayout({
showTabs?: boolean
action?: ReactNode
}) {
const tabs = [
{ to: '/settings', label: 'حساب', end: true },
{ to: '/settings/organization', label: 'پروفایل سازمان' },
]
return (
<PageShell width="md">
<PageHeader
@@ -30,10 +34,7 @@ export function SettingsLayout({
/>
{showTabs ? (
<FeatureTabs
items={[
{ to: '/settings', label: 'حساب', end: true },
{ to: '/settings/organization', label: 'پروفایل سازمان' },
]}
items={tabs}
/>
) : null}
{children}
+85 -3
View File
@@ -12,8 +12,10 @@ export const en = {
data: 'Data',
dataNone: 'No data source',
dataEmpty: 'No data source registered',
dataConnections: 'Connections',
dataFiles: 'Data file',
dataConnections: 'Databases',
dataFiles: 'Files',
dataApps: 'Connections',
clearData: 'Clear',
dataConnection: 'SQL connection',
uploadData: 'Upload a data file',
database: 'Database',
@@ -25,6 +27,10 @@ export const en = {
knowledge: 'Knowledge',
knowledgeNone: 'No knowledge source',
knowledgeEmpty: 'No knowledge source registered',
connectors: 'Connectors',
connectorsNone: 'No business app',
connectorsEmpty: 'No business app connected',
connectorHint: 'Business app',
skill: 'Skill',
skillDefault: 'Default skill',
skillEmpty: 'No skill registered',
@@ -45,6 +51,9 @@ export const en = {
writeReport: 'Write report',
shellCommand: 'Shell command',
askUser: 'Ask user',
connectorRunning: 'Fetching data from Didar…',
connectorDone: 'Didar data received',
connectorFailed: 'Could not fetch Didar data',
reactDetail: 'Thought / Action / Observation',
processing: 'Processing…',
thinking: 'Thinking…',
@@ -75,7 +84,10 @@ export const en = {
},
panel: {
execution: 'Execution',
parallelTasks: 'Parallel tasks',
subagent: 'Sub-agent',
categoryReport: 'Report',
categoryConnector: 'Connector',
categorySkill: 'Skill',
skillLoaded: 'Skill loaded',
files: 'Files',
@@ -146,7 +158,7 @@ export const en = {
sourceUrl: 'URL',
sourceText: 'Text',
chooseFiles: 'Choose files',
fileHint: 'pdf, Word, text, and markdown are supported. Upload Excel and CSV in Databases.',
fileHint: 'pdf, Word, text, and markdown are supported. Upload Excel, CSV, and JSON from Data, on the Files tab.',
libraryEmpty: 'No knowledge documents saved yet.',
url: 'URL',
textTitle: 'Text title',
@@ -287,6 +299,76 @@ export const en = {
hintMongoPort: 'Default 27017',
hintMongoAuth: 'Usually admin',
},
dataHub: {
sectionLabel: 'Data',
tabDashboard: 'Dashboard',
tabFiles: 'Files',
tabDatabases: 'Databases',
tabApps: 'Connections',
tabMap: 'Organization map',
dashboardTitle: 'Data dashboard',
dashboardSubtitle: 'Files, databases, and business apps live here. If nothing is connected yet, start from the cards below.',
dashboardEmptyKicker: 'Get started',
dashboardEmptyTitle: 'Build the organization’s data in one place',
dashboardEmptyBody: 'You do not need data already loaded. Add a file, connect a database, or attach an app such as Didar. Structure and links then show on the organization map.',
stepFiles: 'Files',
stepFilesHint: 'Excel, CSV, or JSON',
stepDatabases: 'Databases',
stepDatabasesHint: 'MySQL, PostgreSQL, and others',
stepApps: 'Apps',
stepAppsHint: 'Didar and organizational apps',
stepMap: 'Organization map',
stepMapHint: 'Discover structure and links',
suggestFiles: 'Start with a file',
suggestDatabases: 'Or connect a database',
suggestApps: 'Or a business app',
suggestAppsBody: 'Didar can be connected now. Other apps, such as an ERP, are added from the same catalog.',
openFiles: 'Go to files',
openDatabases: 'Go to databases',
openApps: 'Go to connections',
openOrgMap: 'Open the organization map',
statFiles: 'Files',
statDatabases: 'Databases',
statApps: 'App connections',
statDiscovered: 'Discovered sources',
mixLabel: 'Source mix',
recentFiles: 'Recent files',
recentDatabases: 'Connected databases',
recentApps: 'Connected apps',
noneYet: 'Nothing here yet',
add: 'Add',
view: 'View',
confirmedLinks: '{count} confirmed links',
mapProgress: '{ready} of {total} sources are discovered on the map.',
mapReady: 'Source structure is ready on the organization map.',
sourcesTitle: 'Files',
sourcesSubtitle: 'Keep Excel, CSV, and JSON here. The assistant and the organization map read this library.',
addFile: 'Add file',
dropTitle: 'Drop a file here',
dropHint: 'Excel, CSV, JSON, or Parquet. You can select several files at once.',
dropCompact: 'Drop another file, or choose one from your computer.',
browse: 'Choose from computer',
fileCount: '{count} files',
hintExcel: 'Sheets and tables',
hintCsv: 'Text rows',
hintJson: 'Structured records',
searchPlaceholder: 'Search file names',
noMatches: 'No file matches this filter.',
statusProcessing: 'Processing',
statusFailed: 'Failed',
connectionsTitle: 'Databases',
connectionsSubtitle: 'Connect an organizational database. This name is what chat uses when picking a source.',
savedTitle: 'Saved connections',
savedEmptyTitle: 'No connection saved yet',
savedEmptyBody: 'Pick an engine and enter the access details.',
addEngineTitle: 'New engine',
addEngineHint: 'Each engine has its own settings form.',
groupSql: 'Relational',
groupOther: 'File, graph, and document',
manageOnly: 'Only a workspace admin can create or edit connections.',
connectionCount: '{count} connections',
openMap: 'Discover structure and links on the organization map',
},
dataMap: {
factsTitle: 'Field facts',
role: 'Role',
+85 -3
View File
@@ -12,8 +12,10 @@ export const fa = {
data: 'داده',
dataNone: 'بدون منبع داده',
dataEmpty: 'منبع داده‌ای ثبت نشده',
dataConnections: 'اتصال‌ها',
dataFiles: 'فایل داده',
dataConnections: 'دیتابیس',
dataFiles: 'فایل‌ها',
dataApps: 'اتصالات',
clearData: 'پاک کردن',
dataConnection: 'اتصال SQL',
uploadData: 'بارگذاری فایل داده',
database: 'پایگاه داده',
@@ -25,6 +27,10 @@ export const fa = {
knowledge: 'دانش',
knowledgeNone: 'بدون منبع دانش',
knowledgeEmpty: 'منبع دانشی ثبت نشده',
connectors: 'اتصال‌ها',
connectorsNone: 'بدون اتصال نرم‌افزار',
connectorsEmpty: 'نرم‌افزاری وصل نشده',
connectorHint: 'نرم‌افزار سازمانی',
skill: 'مهارت',
skillDefault: 'مهارت پیش‌فرض',
skillEmpty: 'مهارتی ثبت نشده',
@@ -45,6 +51,9 @@ export const fa = {
writeReport: 'نوشتن گزارش',
shellCommand: 'فرمان شل',
askUser: 'سؤال از کاربر',
connectorRunning: 'در حال دریافت اطلاعات از دیدار…',
connectorDone: 'اطلاعات دیدار دریافت شد',
connectorFailed: 'خطا در دریافت اطلاعات از دیدار',
reactDetail: 'تفکر / اقدام / مشاهده',
processing: 'در حال پردازش…',
thinking: 'در حال فکر کردن…',
@@ -71,6 +80,7 @@ export const fa = {
categoryReport: 'گزارش',
categoryShell: 'Shell',
categoryTool: 'ابزار',
categoryConnector: 'اتصال',
categorySkill: 'مهارت',
skillLoaded: 'مهارت بارگذاری‌شده',
statusRunning: 'در حال اجرا',
@@ -98,6 +108,8 @@ export const fa = {
summaryPlaceholder: 'پس از اتمام، پاسخ نهایی اینجا ظاهر می‌شود.',
showExecution: 'جزئیات اجرا',
hide: 'بستن پنل',
parallelTasks: 'کارهای موازی',
subagent: 'زیرعامل',
},
files: {
empty: 'هنوز فایلی در این فضای کاری وجود ندارد.',
@@ -173,7 +185,7 @@ export const fa = {
sourceUrl: 'نشانی وب',
sourceText: 'متن',
chooseFiles: 'انتخاب فایل',
fileHint: 'pdf، Word، متن و markdown پشتیبانی می‌شوند. Excel و CSV را در پایگاه داده بارگذاری کنید.',
fileHint: 'pdf، Word، متن و markdown پشتیبانی می‌شوند. Excel، CSV و JSON را در بخش داده، تب فایل‌ها بارگذاری کنید.',
libraryEmpty: 'سندی برای دانش ذخیره نشده.',
url: 'نشانی وب',
textTitle: 'عنوان متن',
@@ -314,6 +326,76 @@ export const fa = {
hintMongoPort: 'پیش‌فرض 27017',
hintMongoAuth: 'معمولاً admin',
},
dataHub: {
sectionLabel: 'بخش داده',
tabDashboard: 'داشبورد',
tabFiles: 'فایل‌ها',
tabDatabases: 'دیتابیس',
tabApps: 'اتصالات',
tabMap: 'نقشه سازمان',
dashboardTitle: 'داشبورد داده',
dashboardSubtitle: 'همهٔ فایل‌ها، پایگاه‌ها و نرم‌افزارهای سازمان اینجاست. اگر هنوز چیزی وصل نشده، از کارت‌های پایین شروع کنید.',
dashboardEmptyKicker: 'شروع کار',
dashboardEmptyTitle: 'دادهٔ سازمان را از یک جا بسازید',
dashboardEmptyBody: 'لازم نیست از قبل داده‌ای داشته باشید. یک فایل بگذارید، یک پایگاه وصل کنید، یا نرم‌افزاری مثل دیدار را متصل کنید. بعد ساختارشان روی نقشهٔ سازمان دیده می‌شود.',
stepFiles: 'فایل',
stepFilesHint: 'Excel، CSV یا JSON',
stepDatabases: 'دیتابیس',
stepDatabasesHint: 'MySQL، PostgreSQL و بقیه',
stepApps: 'نرم‌افزار',
stepAppsHint: 'دیدار و اتصال‌های سازمانی',
stepMap: 'نقشه سازمان',
stepMapHint: 'کشف ساختار و ارتباط‌ها',
suggestFiles: 'با یک فایل شروع کنید',
suggestDatabases: 'یا یک پایگاه وصل کنید',
suggestApps: 'یا نرم‌افزار سازمان',
suggestAppsBody: 'دیدار همین حالا وصل می‌شود. اتصال‌های دیگر، مثل ERP، از همین فهرست اضافه می‌شوند.',
openFiles: 'رفتن به فایل‌ها',
openDatabases: 'رفتن به دیتابیس',
openApps: 'رفتن به اتصالات',
openOrgMap: 'باز کردن نقشه سازمان',
statFiles: 'فایل',
statDatabases: 'دیتابیس',
statApps: 'اتصال نرم‌افزار',
statDiscovered: 'منبع کشف‌شده',
mixLabel: 'ترکیب منابع',
recentFiles: 'فایل‌های اخیر',
recentDatabases: 'پایگاه‌های وصل‌شده',
recentApps: 'نرم‌افزارهای وصل‌شده',
noneYet: 'هنوز موردی نیست',
add: 'افزودن',
view: 'مشاهده',
confirmedLinks: '{count} ارتباط تأییدشده',
mapProgress: '{ready} از {total} منبع روی نقشه کشف شده‌اند.',
mapReady: 'ساختار منبع‌ها روی نقشهٔ سازمان آماده است.',
sourcesTitle: 'فایل‌ها',
sourcesSubtitle: 'Excel، CSV و JSON را اینجا بگذارید. دستیار و نقشهٔ سازمان از همین فایل‌ها می‌خوانند.',
addFile: 'افزودن فایل',
dropTitle: 'فایل را اینجا رها کنید',
dropHint: 'Excel، CSV، JSON یا Parquet. چند فایل را می‌توان با هم انتخاب کرد.',
dropCompact: 'فایل تازه را رها کنید، یا از رایانه انتخاب کنید.',
browse: 'انتخاب از رایانه',
fileCount: '{count} فایل',
hintExcel: 'شیت و جدول',
hintCsv: 'ردیف‌های متنی',
hintJson: 'رکورد ساخت‌یافته',
searchPlaceholder: 'جستجو در نام فایل',
noMatches: 'فایلی با این مشخصات پیدا نشد.',
statusProcessing: 'در حال پردازش',
statusFailed: 'ناموفق',
connectionsTitle: 'دیتابیس',
connectionsSubtitle: 'پایگاه سازمان را وصل کنید. نام هر پایگاه در گفتگو برای انتخاب منبع دیده می‌شود.',
savedTitle: 'اتصال‌های ذخیره‌شده',
savedEmptyTitle: 'هنوز اتصالی ذخیره نشده',
savedEmptyBody: 'یک موتور را انتخاب کنید و مشخصات دسترسی را وارد کنید.',
addEngineTitle: 'موتور جدید',
addEngineHint: 'هر موتور فرم تنظیمات خودش را دارد.',
groupSql: 'رابطه‌ای',
groupOther: 'فایل، گراف و سند',
manageOnly: 'ساخت و ویرایش اتصال برای مدیر فضای کاری است.',
connectionCount: '{count} اتصال',
openMap: 'کشف ساختار و ارتباط‌ها در نقشه سازمان',
},
dataMap: {
factsTitle: 'واقعیت‌های فیلد',
role: 'نقش',
+68 -15
View File
@@ -7,6 +7,7 @@ import { useAssistantRuntime } from '../app/workspace/AssistantRuntimeContext'
import { ensureConversation } from '../features/assistant/api/conversations.api'
import { replyQuestion, rejectQuestion } from '../features/assistant/api/chat.api'
import { listDatasources, listKnowledgeSpaces, listSkills } from '../features/assistant/api/resources.api'
import { listConnectors } from '../features/connectors/api'
import { messagesToTurns } from '../features/assistant/adapters/runToTurn'
import { collectPinnedResources, collectTurnInputFiles, humanForView } from '../features/assistant/adapters/historyToMessages'
import { ExecutionPanel } from '../features/assistant/execution/ExecutionPanel'
@@ -41,7 +42,8 @@ import { skillSettingsPath } from '../features/settings/api'
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
export function AssistantPage() {
const { developerMode } = useFeatureFlags()
const { developerMode, isEnabled } = useFeatureFlags()
const connectorsEnabled = isEnabled('connectors')
const location = useLocation()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
@@ -50,8 +52,10 @@ export function AssistantPage() {
const [query, setQuery] = useState('')
const [databases, setDatabases] = useState<ResourceOption[]>([])
const [knowledge, setKnowledge] = useState<ResourceOption[]>([])
const [connectors, setConnectors] = useState<ResourceOption[]>([])
const [skills, setSkills] = useState<ResourceOption[]>([])
const [selectedDbs, setSelectedDbs] = useState<ResourceOption[]>([])
const [selectedConnectors, setSelectedConnectors] = useState<ResourceOption[]>([])
const [selectedKnowledge, setSelectedKnowledge] = useState<ResourceOption | null>(null)
const [selectedSkill, setSelectedSkill] = useState<ResourceOption | null>(null)
const [reasoningMode, setReasoningMode] = useState<'fast' | 'medium' | 'deep'>('fast')
@@ -95,6 +99,27 @@ export function AssistantPage() {
refreshLibrary()
}, [refreshLibrary])
useEffect(() => {
if (!connectorsEnabled) {
setConnectors([])
setSelectedConnectors([])
return
}
void listConnectors()
.then((rows) =>
setConnectors(
rows
.filter((item) => item.status === 'active')
.map((item) => ({
id: item.id,
name: item.display_name,
type: 'didar',
})),
),
)
.catch(() => setConnectors([]))
}, [connectorsEnabled])
useEffect(() => {
const skillId = searchParams.get('skill')
if (!skillId || !skills.length || convId) return
@@ -121,6 +146,7 @@ export function AssistantPage() {
sessionFiles.resetSession()
setConversationId(null)
setSelectedDbs([])
setSelectedConnectors([])
setSelectedKnowledge(null)
setSelectedSkill(null)
setSelectedStepId(null)
@@ -169,6 +195,7 @@ export function AssistantPage() {
if (sessionFiles.sessionId !== convId) {
if (sessionFiles.sessionId) sessionFiles.resetSession()
setSelectedDbs([])
setSelectedConnectors([])
setSelectedKnowledge(null)
setSelectedSkill(null)
restoredFilesFor.current = null
@@ -193,7 +220,7 @@ export function AssistantPage() {
if (pins.files.length) sessionFiles.attachLibraryFiles(pins.files, convId)
}
if (restoredCatalogFor.current === convId) return
if (!databases.length && !knowledge.length) return
if (!databases.length && !knowledge.length && !connectors.length) return
restoredCatalogFor.current = convId
if (pins.databases.length) {
const matches = pins.databases
@@ -209,9 +236,13 @@ export function AssistantPage() {
)
if (match) setSelectedKnowledge(match)
}
// attachLibraryFiles is stable; avoid depending on the whole sessionFiles object.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [convId, databases, knowledge, sessionFiles.attachLibraryFiles, stream.conversationId, stream.messages])
if (pins.connectors.length) {
const matches = pins.connectors
.map((pin) => connectors.find((item) => item.id === pin.id || item.name === pin.name))
.filter((item): item is ResourceOption => Boolean(item))
if (matches.length) setSelectedConnectors(matches)
}
}, [convId, connectors, databases, knowledge, sessionFiles.attachLibraryFiles, stream.conversationId, stream.messages])
const rounds = useMemo(() => messagesToTurns(stream.messages), [stream.messages])
const isWelcome = rounds.length === 0
@@ -280,6 +311,7 @@ export function AssistantPage() {
attachedDb: selectedDbs[0]
? { db_name: selectedDbs[0].name, db_type: selectedDbs[0].type || '' }
: activeHuman?.attachedDb,
attachedConnectors: selectedConnectors.map((item) => ({ id: item.id, name: item.name })),
loading: stream.loading,
developerMode,
}
@@ -312,7 +344,7 @@ export function AssistantPage() {
sendSnapshot = await sessionFiles.prepare(currentConvId)
}
const resources: Array<{ id: string; type: 'file' | 'datasource' | 'knowledge'; name?: string }> = []
const resources: Array<{ id: string; type: 'file' | 'datasource' | 'knowledge' | 'connector'; name?: string }> = []
if (sendSnapshot) {
for (const file of snapshotsForSend(sendSnapshot)) {
resources.push({ id: file.file_id, type: 'file', name: file.name })
@@ -321,6 +353,9 @@ export function AssistantPage() {
for (const item of selectedDbs) {
resources.push({ id: item.id, type: 'datasource', name: item.name })
}
for (const item of selectedConnectors) {
resources.push({ id: item.id, type: 'connector', name: item.name })
}
if (selectedKnowledge) resources.push({ id: selectedKnowledge.id, type: 'knowledge', name: selectedKnowledge.name })
const humanId = uuidv4()
@@ -358,8 +393,9 @@ export function AssistantPage() {
attachedKnowledge: selectedKnowledge
? { name: selectedKnowledge.name, id: selectedKnowledge.id }
: undefined,
attachedConnectors: selectedConnectors.map((item) => ({ id: item.id, name: item.name })),
},
viewMsg: { id: viewId, role: 'view', context: '', order, thinking: true },
viewMsg: { id: viewId, role: 'view', context: '', order, thinking: true, reportMode },
})
.finally(() => {
void refreshConversations()
@@ -397,7 +433,9 @@ export function AssistantPage() {
.filter((file): file is SessionFileSnapshot => Boolean(file?.file_id)),
[sessionFiles.files],
)
const hasConversationPins = Boolean(pinnedFiles.length || selectedDbs.length || selectedKnowledge)
const hasConversationPins = Boolean(
pinnedFiles.length || selectedDbs.length || selectedKnowledge || selectedConnectors.length,
)
const dockDrafts = sessionFiles.files.filter((draft) => draft.upload.status !== 'done')
const handleRemovePinnedFile = useCallback(
@@ -464,12 +502,15 @@ export function AssistantPage() {
attachedFileIds={attachedFileIds}
onToggleLibraryFile={handleToggleLibraryFile}
onUploadNew={() => libraryFileInputRef.current?.click()}
connectors={connectors}
selectedConnectors={selectedConnectors}
onSelectConnectors={connectorsEnabled ? setSelectedConnectors : undefined}
/>
)
return (
<div className="flex h-full min-h-0 min-w-0 overflow-x-hidden" dir="ltr">
<div className="flex min-w-0 flex-1 flex-col overflow-x-hidden" dir="rtl">
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden" dir="rtl">
{isWelcome ? (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="lp-root relative flex min-h-0 min-w-0 flex-1 flex-col items-center justify-center overflow-x-hidden overflow-y-auto px-3">
@@ -495,8 +536,12 @@ export function AssistantPage() {
onAddFiles={handlePickLocalFiles}
pinnedDatabases={selectedDbs}
pinnedKnowledge={selectedKnowledge}
pinnedConnectors={selectedConnectors}
onRemoveDatabase={(id) => setSelectedDbs((prev) => prev.filter((item) => item.id !== id))}
onClearKnowledge={() => setSelectedKnowledge(null)}
onRemoveConnector={(id: string) =>
setSelectedConnectors((prev) => prev.filter((item) => item.id !== id))
}
toolbar={toolsBar}
/>
</div>
@@ -505,7 +550,7 @@ export function AssistantPage() {
) : (
<>
<div
className={`grid min-h-0 flex-1 ${
className={`grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] ${
showExecutionPanel
? 'gap-2 p-2 md:gap-3 md:p-3 grid-cols-1 lg:grid-cols-[minmax(280px,0.85fr)_minmax(0,1.7fr)]'
: 'grid-cols-1'
@@ -513,14 +558,15 @@ export function AssistantPage() {
dir="ltr"
>
{showExecutionPanel ? (
<div className="assistant-panel-enter hidden min-h-0 lg:block">
<div className="assistant-panel-enter hidden h-full min-h-0 lg:flex">
<ExecutionPanel
{...executionPanelProps}
className="h-full min-h-0 w-full"
onCollapse={() => setPanelCollapsed(true)}
/>
</div>
) : null}
<div className="flex min-h-0 min-w-0 flex-col" dir="rtl">
<div className="flex h-full min-h-0 min-w-0 flex-col" dir="rtl">
{stream.runStatus === 'waiting_for_user' ? (
<div className="mb-2 rounded-lg bg-warn-soft px-3 py-2 text-xs text-warn chat-enter">
{t('assistant.execution.waitingForUser')}
@@ -540,11 +586,15 @@ export function AssistantPage() {
db_name: item.name,
db_type: item.type || '',
}))}
connectors={selectedConnectors.map((item) => ({ id: item.id, name: item.name }))}
onPreviewFile={(file) => void handlePreviewFile(file)}
onRemoveFile={handleRemovePinnedFile}
onRemoveDatabase={(key) =>
setSelectedDbs((prev) => prev.filter((item) => item.id !== key && item.name !== key))
}
onRemoveConnector={(id: string) =>
setSelectedConnectors((prev) => prev.filter((item) => item.id !== id))
}
onClearKnowledge={() => setSelectedKnowledge(null)}
/>
</div>
@@ -563,9 +613,6 @@ export function AssistantPage() {
onRetryStopped={() => void stream.retryStopped()}
onPreviewFile={(file) => void handlePreviewFile(file)}
/>
</div>
</div>
<EnhancedChatInput
variant="dock"
query={query}
@@ -580,10 +627,16 @@ export function AssistantPage() {
onAddFiles={handlePickLocalFiles}
pinnedDatabases={selectedDbs}
pinnedKnowledge={selectedKnowledge}
pinnedConnectors={selectedConnectors}
onRemoveDatabase={(id) => setSelectedDbs((prev) => prev.filter((item) => item.id !== id))}
onClearKnowledge={() => setSelectedKnowledge(null)}
onRemoveConnector={(id: string) =>
setSelectedConnectors((prev) => prev.filter((item) => item.id !== id))
}
toolbar={toolsBar}
/>
</div>
</div>
</>
)}
</div>
-356
View File
@@ -1,356 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Database, FileSpreadsheet, RefreshCw, Trash2, Upload } from 'lucide-react'
import { Button } from '../components/Button'
import { PageShell } from '../components/ui/PageShell'
import { PageHeader } from '../components/ui/PageHeader'
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
import { Badge } from '../components/ui/StatusBadge'
import { CardGridSkeleton } from '../components/ui/Skeleton'
import {
deleteFile,
listWorkspaceFiles,
uploadWorkspaceFile,
type KamidsoFile,
} from '../features/assistant/session-files'
import { FileTitleModal } from '../features/assistant/session-files/FileTitleModal'
import {
DATA_FILE_ACCEPT,
fileKindLine,
isDataFileKind,
isDataFilename,
} from '../features/assistant/session-files/fileKind'
import { formatBytes } from '../features/assistant/session-files/attachment-view-model'
import { ConnectEngineModal } from '../features/databases/ConnectEngineModal'
import { EngineMark } from '../features/databases/EngineMark'
import { ENGINES, engineById, type EngineId } from '../features/databases/engines'
import {
deleteDatasource,
listDatasourceRecords,
type DatasourceRecord,
} from '../features/databases/api'
import { t } from '../i18n'
import { useAuth } from '../features/auth/AuthContext'
export function DatabasesPage() {
const { user } = useAuth()
const canManage = user?.role !== 'user'
const [items, setItems] = useState<DatasourceRecord[]>([])
const [files, setFiles] = useState<KamidsoFile[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [pendingUpload, setPendingUpload] = useState<File[] | null>(null)
const [deletingFileId, setDeletingFileId] = useState<string | null>(null)
const [deletingConnId, setDeletingConnId] = useState<string | null>(null)
const [connectEngine, setConnectEngine] = useState<EngineId | null>(null)
const [editing, setEditing] = useState<DatasourceRecord | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dataFiles = useMemo(
() =>
files.filter(
(file) =>
isDataFileKind(file.kind) || isDataFilename(file.original_name || file.name || ''),
),
[files],
)
async function load() {
setLoading(true)
setError(null)
const errors: string[] = []
const [datasources, workspaceFiles] = await Promise.all([
listDatasourceRecords().catch((err) => {
errors.push(err instanceof Error ? err.message : 'بارگذاری اتصال‌ها ناموفق بود')
return [] as DatasourceRecord[]
}),
listWorkspaceFiles().catch((err) => {
errors.push(err instanceof Error ? err.message : t('databases.fileUploadFailed'))
return [] as KamidsoFile[]
}),
])
setItems(datasources)
setFiles(workspaceFiles)
setError(errors[0] || null)
setLoading(false)
}
useEffect(() => {
void load()
}, [])
function takeFiles(list: FileList | File[]) {
const next = Array.from(list).filter((file) => isDataFilename(file.name))
if (!next.length) {
setError(t('databases.fileTypeError'))
return
}
setError(null)
setPendingUpload(next)
}
async function confirmUpload(nextFiles: File[], titles: string[]) {
setPendingUpload(null)
try {
for (let index = 0; index < nextFiles.length; index += 1) {
await uploadWorkspaceFile(nextFiles[index]!, titles[index])
}
await load()
} catch (err) {
setError(err instanceof Error ? err.message : t('databases.fileUploadFailed'))
}
}
async function confirmDeleteFile() {
if (!deletingFileId) return
const id = deletingFileId
setDeletingFileId(null)
try {
await deleteFile('', id)
setFiles((current) => current.filter((file) => file.id !== id))
} catch (err) {
setError(err instanceof Error ? err.message : t('databases.fileDeleteFailed'))
}
}
async function confirmDeleteConnection() {
if (!deletingConnId) return
const id = deletingConnId
setDeletingConnId(null)
try {
await deleteDatasource(id)
setItems((current) => current.filter((item) => item.id !== id))
} catch (err) {
setError(err instanceof Error ? err.message : t('databases.deleteFailed'))
}
}
function connectionMeta(item: DatasourceRecord) {
const engine = engineById(item.type)
const host = item.host || String(item.config?.host || '')
const database = item.database || String(item.config?.database || item.config?.path || '')
if (engine.id === 'sqlite') return database || t('databases.kindFile')
return [host, database].filter(Boolean).join(' · ')
}
return (
<PageShell>
<PageHeader
icon={Database}
title={t('databases.pageTitle')}
subtitle={t('databases.pageSubtitle')}
action={
<button
type="button"
onClick={() => void load()}
className="rounded-xl border border-line p-2 text-ink-muted hover:text-accent"
aria-label={t('databases.refresh')}
>
<RefreshCw className="h-4 w-4" />
</button>
}
/>
{error ? <p className="mb-4 text-sm text-danger">{error}</p> : null}
<section className="mb-6">
<div className="mb-3">
<h2 className="text-sm font-semibold text-ink">{t('databases.galleryTitle')}</h2>
<p className="mt-0.5 text-[12px] text-ink-muted">{t('databases.galleryHint')}</p>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 xl:grid-cols-4">
{ENGINES.map((engine) => {
const count = items.filter((item) => item.type === engine.id).length
return (
<button
key={engine.id}
type="button"
disabled={!canManage}
onClick={() => {
setEditing(null)
setConnectEngine(engine.id)
}}
className="group flex items-center gap-2.5 rounded-2xl border border-line bg-panel px-2.5 py-2 text-right transition hover:border-line-strong hover:shadow-sm disabled:cursor-not-allowed disabled:opacity-60"
style={{ backgroundColor: engine.accentSoft }}
>
<EngineMark id={engine.id} className="h-9 w-9" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate text-[13px] font-semibold text-ink">{engine.label}</span>
{count ? <Badge tone="accent">{count}</Badge> : null}
</span>
<span className="mt-0.5 block truncate text-[11px] text-ink-muted">
{t(engine.kindKey)}
</span>
</span>
<span
className="inline-flex h-7 shrink-0 items-center rounded-full px-2.5 text-[11px] font-medium text-white"
style={{ background: engine.accent }}
>
{count ? t('databases.addAnother') : t('databases.configure')}
</span>
</button>
)
})}
</div>
</section>
<section className="mb-6">
<div className="mb-4 flex items-end justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-ink">{t('databases.connectedTitle')}</h2>
<p className="mt-0.5 text-[12px] text-ink-muted">{t('databases.connectedHint')}</p>
</div>
{items.length ? <Badge>{items.length}</Badge> : null}
</div>
{loading ? (
<CardGridSkeleton count={2} className="grid gap-3 md:grid-cols-2" />
) : items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-line bg-panel px-4 py-8 text-center text-sm text-ink-muted">
{t('databases.emptyConnections')}
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{items.map((item) => {
const engine = engineById(item.type)
return (
<div
key={item.id}
className={`flex items-start gap-3 rounded-2xl border border-line bg-panel p-3.5 shadow-[0_1px_2px_rgb(23_32_51/0.04)] transition ${
canManage
? 'cursor-pointer hover:-translate-y-0.5 hover:border-line-strong hover:shadow-md'
: ''
}`}
onClick={() => canManage && setEditing(item)}
onKeyDown={(event) => {
if (!canManage) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setEditing(item)
}
}}
role={canManage ? 'button' : undefined}
tabIndex={canManage ? 0 : undefined}
>
<EngineMark id={engine.id} className="h-11 w-11" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-ink">{item.name}</h3>
<Badge tone="accent">{engine.label}</Badge>
</div>
<p className="mt-1 truncate text-[12px] text-ink-muted" dir="ltr">
{connectionMeta(item)}
</p>
</div>
{canManage ? (
<button
type="button"
className="rounded-lg p-1.5 text-ink-muted transition hover:bg-danger-soft hover:text-danger"
aria-label={t('databases.deleteTitle')}
onClick={(event) => {
event.stopPropagation()
setDeletingConnId(item.id)
}}
>
<Trash2 className="h-4 w-4" />
</button>
) : null}
</div>
)
})}
</div>
)}
</section>
<section>
<div className="mb-4 flex items-end justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-ink">{t('databases.filesTitle')}</h2>
<p className="mt-0.5 text-[12px] text-ink-muted">{t('databases.filesHint')}</p>
</div>
<Button variant="secondary" onClick={() => fileInputRef.current?.click()}>
<Upload className="h-4 w-4" />
{t('databases.addDataFile')}
</Button>
</div>
{dataFiles.length === 0 ? (
<div className="rounded-2xl border border-dashed border-line bg-panel px-4 py-8 text-center text-sm text-ink-muted">
{t('databases.emptyFiles')}
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{dataFiles.map((file) => (
<div
key={file.id}
className="flex items-start gap-3 rounded-2xl border border-line bg-panel p-3.5"
>
<span className="rounded-xl bg-accent-soft p-2 text-accent">
<FileSpreadsheet className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<h3 className="truncate font-semibold text-ink">{file.title?.trim() || file.name}</h3>
<p className="text-xs text-ink-muted">{fileKindLine(file.kind)}</p>
<p className="mt-1 text-xs text-ink-subtle">
{file.original_name || file.name}
{file.size_bytes != null ? ` · ${formatBytes(file.size_bytes)}` : ''}
</p>
</div>
<button
type="button"
className="rounded-lg p-1.5 text-ink-muted transition hover:bg-danger-soft hover:text-danger"
aria-label={t('databases.deleteFile')}
onClick={() => setDeletingFileId(file.id)}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</section>
<input
ref={fileInputRef}
type="file"
multiple
accept={DATA_FILE_ACCEPT}
className="hidden"
onChange={(event) => {
if (event.target.files?.length) takeFiles(event.target.files)
event.target.value = ''
}}
/>
<FileTitleModal
files={pendingUpload}
onClose={() => setPendingUpload(null)}
onConfirm={(nextFiles, titles) => void confirmUpload(nextFiles, titles)}
/>
<ConnectEngineModal
open={Boolean(connectEngine) || Boolean(editing)}
engineId={connectEngine || (editing?.type as EngineId) || 'mysql'}
existing={editing}
onClose={() => {
setConnectEngine(null)
setEditing(null)
}}
onSaved={() => void load()}
/>
<ConfirmDialog
open={Boolean(deletingFileId)}
tone="danger"
title={t('databases.deleteFile')}
message={t('databases.deleteFileMessage')}
confirmLabel={t('databases.delete')}
onConfirm={() => void confirmDeleteFile()}
onCancel={() => setDeletingFileId(null)}
/>
<ConfirmDialog
open={Boolean(deletingConnId)}
tone="danger"
title={t('databases.deleteTitle')}
message={t('databases.deleteMessage')}
confirmLabel={t('databases.delete')}
onConfirm={() => void confirmDeleteConnection()}
onCancel={() => setDeletingConnId(null)}
/>
</PageShell>
)
}
+1 -17
View File
@@ -1,22 +1,13 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchSharedReport } from '../features/assistant/api/reports.api'
import { downloadReportPdf } from '../features/assistant/report/downloadReportPdf'
import { ShareReportPage } from './ShareReportPage'
vi.mock('../features/assistant/api/reports.api', () => ({
fetchSharedReport: vi.fn(),
}))
vi.mock('../features/assistant/report/downloadReportPdf', async () => {
const actual = await vi.importActual<typeof import('../features/assistant/report/downloadReportPdf')>(
'../features/assistant/report/downloadReportPdf',
)
return { ...actual, downloadReportPdf: vi.fn() }
})
function renderShare(path = '/share/reports/rpt_abc') {
return render(
<MemoryRouter initialEntries={[path]}>
@@ -30,7 +21,6 @@ function renderShare(path = '/share/reports/rpt_abc') {
describe('ShareReportPage', () => {
beforeEach(() => {
vi.mocked(fetchSharedReport).mockReset()
vi.mocked(downloadReportPdf).mockClear()
})
it('shows the report full screen without app navigation', async () => {
@@ -39,18 +29,12 @@ describe('ShareReportPage', () => {
title: 'فروش',
html: "<html dir='rtl'><body><h1>فروش</h1></body></html>",
})
const user = userEvent.setup()
renderShare()
expect(await screen.findByTitle('html-preview-share')).toBeInTheDocument()
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'باز کردن منو' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'دانلود PDF' }))
expect(downloadReportPdf).toHaveBeenCalledWith(
"<html dir='rtl'><body><h1>فروش</h1></body></html>",
'فروش',
)
expect(screen.queryByRole('button', { name: 'دانلود PDF' })).not.toBeInTheDocument()
})
it('shows a not-found state without asking for login', async () => {
+1 -12
View File
@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { Download } from 'lucide-react'
import { t } from '../i18n'
import { fetchSharedReport } from '../features/assistant/api/reports.api'
import { downloadReportPdf, reportTitleFromHtml } from '../features/assistant/report/downloadReportPdf'
import { reportTitleFromHtml } from '../features/assistant/report/downloadReportPdf'
type LoadState =
| { status: 'loading' }
@@ -74,8 +73,6 @@ export function ShareReportPage() {
)
}
const downloadLabel = t('assistant.report.downloadPdf')
return (
<div className="relative h-dvh w-full overflow-hidden bg-white">
<iframe
@@ -84,14 +81,6 @@ export function ShareReportPage() {
className="h-full w-full bg-white"
srcDoc={state.html}
/>
<button
type="button"
onClick={() => downloadReportPdf(state.html, state.title)}
className="fixed bottom-4 end-4 z-10 inline-flex items-center gap-2 rounded-full bg-ink px-4 py-2.5 text-sm font-medium text-white shadow-lg transition hover:bg-ink/90"
>
<Download className="h-4 w-4" />
{downloadLabel}
</button>
</div>
)
}
+8 -1
View File
@@ -1,22 +1,29 @@
/// <reference types="vitest/config" />
import http from 'node:http'
import https from 'node:https'
import { defineConfig, loadEnv, type ProxyOptions } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { apiOrigin } from './src/services/apiBase'
function apiProxy(target: string): ProxyOptions {
const secure = target.startsWith('https:')
// keepAlive would let a second chat reuse the socket that is still streaming.
const agent = secure
? new https.Agent({ keepAlive: false })
: new http.Agent({ keepAlive: false })
return {
target,
changeOrigin: true,
timeout: 0,
proxyTimeout: 0,
agent,
configure(proxy) {
proxy.on('proxyRes', (proxyRes, _req, res) => {
const type = String(proxyRes.headers['content-type'] || '')
if (!type.includes('text/event-stream')) return
delete proxyRes.headers['content-length']
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
})
},