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:
1 parent
13c6e7ade6
commit
b17782eec2
59 files changed
+2968
-597
No files matched your search
+3
-1
@@ -18,7 +18,9 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 3600s;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
+17
-1
@@ -4,7 +4,12 @@ import { isNavActive, pageTitleFromPath, PRIMARY_NAV } from './nav'
|
|||||||
describe('app navigation', () => {
|
describe('app navigation', () => {
|
||||||
it('names primary surfaces including the skills catalog', () => {
|
it('names primary surfaces including the skills catalog', () => {
|
||||||
expect(pageTitleFromPath('/assistant')).toBe('دستیار هوشمند')
|
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('/knowledge')).toBe('دانش')
|
||||||
expect(pageTitleFromPath('/skills')).toBe('مهارتها')
|
expect(pageTitleFromPath('/skills')).toBe('مهارتها')
|
||||||
expect(pageTitleFromPath('/settings')).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(PRIMARY_NAV.some((item) => item.id === 'skills' && item.to === '/skills')).toBe(true)
|
||||||
expect(isNavActive('/skills', { to: '/skills', match: ['/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
@@ -2,7 +2,7 @@ export const SIDEBAR_EXPANDED = 260
|
|||||||
export const SIDEBAR_COLLAPSED = 52
|
export const SIDEBAR_COLLAPSED = 52
|
||||||
export const SIDEBAR_COLLAPSED_STORAGE_KEY = 'kamidso.sidebarCollapsed'
|
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 = {
|
export type NavItem = {
|
||||||
id: NavId
|
id: NavId
|
||||||
@@ -14,8 +14,7 @@ export type NavItem = {
|
|||||||
|
|
||||||
export const PRIMARY_NAV: NavItem[] = [
|
export const PRIMARY_NAV: NavItem[] = [
|
||||||
{ id: 'assistant', to: '/assistant', label: 'دستیار هوشمند', match: ['/assistant'] },
|
{ id: 'assistant', to: '/assistant', label: 'دستیار هوشمند', match: ['/assistant'] },
|
||||||
{ id: 'data-map', to: '/data-map', label: 'نقشه داده', match: ['/data-map'] },
|
{ id: 'data', to: '/data', label: 'داده', match: ['/data', '/data-map', '/databases'] },
|
||||||
{ id: 'databases', to: '/databases', label: 'پایگاه داده', match: ['/databases'] },
|
|
||||||
{ id: 'knowledge', to: '/knowledge', label: 'دانش', match: ['/knowledge'] },
|
{ id: 'knowledge', to: '/knowledge', label: 'دانش', match: ['/knowledge'] },
|
||||||
{ id: 'skills', to: '/skills', label: 'مهارتها', match: ['/skills'] },
|
{ id: 'skills', to: '/skills', label: 'مهارتها', match: ['/skills'] },
|
||||||
]
|
]
|
||||||
@@ -29,8 +28,11 @@ export const SETTINGS_NAV: NavItem = {
|
|||||||
|
|
||||||
export function pageTitleFromPath(pathname: string): string {
|
export function pageTitleFromPath(pathname: string): string {
|
||||||
if (pathname === '/home' || pathname.startsWith('/assistant')) return 'دستیار هوشمند'
|
if (pathname === '/home' || pathname.startsWith('/assistant')) return 'دستیار هوشمند'
|
||||||
if (pathname.startsWith('/data-map')) return 'نقشه داده'
|
if (pathname.startsWith('/data/map') || pathname.startsWith('/data-map')) return 'نقشه سازمان'
|
||||||
if (pathname.startsWith('/databases')) 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('/knowledge')) return 'دانش'
|
||||||
if (pathname.startsWith('/skills')) return 'مهارتها'
|
if (pathname.startsWith('/skills')) return 'مهارتها'
|
||||||
if (pathname.startsWith('/settings')) return 'تنظیمات'
|
if (pathname.startsWith('/settings')) return 'تنظیمات'
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type FeatureFlags = {
|
|||||||
widgets: boolean
|
widgets: boolean
|
||||||
radar: boolean
|
radar: boolean
|
||||||
new_agent_workspace: boolean
|
new_agent_workspace: boolean
|
||||||
|
connectors: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_FLAGS: FeatureFlags = {
|
const DEFAULT_FLAGS: FeatureFlags = {
|
||||||
@@ -38,6 +39,7 @@ const DEFAULT_FLAGS: FeatureFlags = {
|
|||||||
widgets: false,
|
widgets: false,
|
||||||
radar: false,
|
radar: false,
|
||||||
new_agent_workspace: true,
|
new_agent_workspace: true,
|
||||||
|
connectors: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeatureFlagContextValue = {
|
type FeatureFlagContextValue = {
|
||||||
@@ -101,3 +103,7 @@ export function useFeatureFlags() {
|
|||||||
if (!ctx) throw new Error('useFeatureFlags must be used within FeatureFlagProvider')
|
if (!ctx) throw new Error('useFeatureFlags must be used within FeatureFlagProvider')
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useFeatureFlagsOptional() {
|
||||||
|
return useContext(FeatureFlagContext)
|
||||||
|
}
|
||||||
+17
-3
@@ -1,14 +1,18 @@
|
|||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { AppShell } from '../components/layout/AppShell'
|
import { AppShell } from '../components/layout/AppShell'
|
||||||
import { AssistantPage } from '../pages/AssistantPage'
|
import { AssistantPage } from '../pages/AssistantPage'
|
||||||
import { DatabasesPage } from '../pages/DatabasesPage'
|
|
||||||
import { DataMapPage } from '../features/data-map/DataMapPage'
|
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 { KnowledgePage } from '../pages/KnowledgePage'
|
||||||
import { KnowledgeDetailPage } from '../pages/KnowledgeDetailPage'
|
import { KnowledgeDetailPage } from '../pages/KnowledgeDetailPage'
|
||||||
import { SkillsPage } from '../pages/SkillsPage'
|
import { SkillsPage } from '../pages/SkillsPage'
|
||||||
import { SettingsPage } from '../pages/SettingsPage'
|
import { SettingsPage } from '../pages/SettingsPage'
|
||||||
import { OrganizationProfilePage } from '../features/settings/OrganizationProfilePage'
|
import { OrganizationProfilePage } from '../features/settings/OrganizationProfilePage'
|
||||||
import { SkillSettingsPage } from '../features/settings/SkillSettingsPage'
|
import { SkillSettingsPage } from '../features/settings/SkillSettingsPage'
|
||||||
|
import { ConnectorsPage } from '../features/connectors/ConnectorsPage'
|
||||||
import { LandingPage } from '../features/landing/pages/LandingPage'
|
import { LandingPage } from '../features/landing/pages/LandingPage'
|
||||||
import { ShareReportPage } from '../pages/ShareReportPage'
|
import { ShareReportPage } from '../pages/ShareReportPage'
|
||||||
import { AuthProvider } from '../features/auth/AuthContext'
|
import { AuthProvider } from '../features/auth/AuthContext'
|
||||||
@@ -37,13 +41,23 @@ export function AppRouter() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/home" element={<Navigate to="/assistant" replace />} />
|
<Route path="/home" element={<Navigate to="/assistant" replace />} />
|
||||||
<Route path="/assistant" element={<AssistantPage />} />
|
<Route path="/assistant" element={<AssistantPage />} />
|
||||||
<Route path="/data-map" element={<DataMapPage />} />
|
<Route path="/data" element={<DataHubLayout />}>
|
||||||
<Route path="/databases" element={<DatabasesPage />} />
|
<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" element={<KnowledgePage />} />
|
||||||
<Route path="/knowledge/:id" element={<KnowledgeDetailPage />} />
|
<Route path="/knowledge/:id" element={<KnowledgeDetailPage />} />
|
||||||
<Route path="/skills" element={<SkillsPage />} />
|
<Route path="/skills" element={<SkillsPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/settings/organization" element={<OrganizationProfilePage />} />
|
<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" element={<Navigate to="/skills" replace />} />
|
||||||
<Route path="/settings/skills/:skillId" element={<SkillSettingsPage />} />
|
<Route path="/settings/skills/:skillId" element={<SkillSettingsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/assistant" replace />} />
|
<Route path="*" element={<Navigate to="/assistant" replace />} />
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useCallback, useEffect, useRef } from 'react'
|
|||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Database,
|
Database,
|
||||||
Network,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
LogOut,
|
LogOut,
|
||||||
MessageSquarePlus,
|
MessageSquarePlus,
|
||||||
@@ -19,8 +18,7 @@ import { useLogoutConfirm } from '../../features/auth/useLogoutConfirm'
|
|||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
assistant: Sparkles,
|
assistant: Sparkles,
|
||||||
'data-map': Network,
|
data: Database,
|
||||||
databases: Database,
|
|
||||||
knowledge: BookOpen,
|
knowledge: BookOpen,
|
||||||
skills: WandSparkles,
|
skills: WandSparkles,
|
||||||
} as const
|
} as const
|
||||||
@@ -117,7 +115,10 @@ export function AppSidebar({
|
|||||||
) : null}
|
) : null}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/assistant"
|
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'}`}
|
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)]">
|
<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)]">
|
||||||
|
|||||||
@@ -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 { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { isNavActive, PRIMARY_NAV } from '../../app/nav'
|
import { isNavActive, PRIMARY_NAV } from '../../app/nav'
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
home: House,
|
home: House,
|
||||||
assistant: Sparkles,
|
assistant: Sparkles,
|
||||||
'data-map': Network,
|
data: Database,
|
||||||
databases: Database,
|
|
||||||
knowledge: BookOpen,
|
knowledge: BookOpen,
|
||||||
skills: WandSparkles,
|
skills: WandSparkles,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
} as const
|
} 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() {
|
export function MobileNav() {
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const items = PRIMARY_NAV.filter((item) => MOBILE_IDS.includes(item.id as (typeof MOBILE_IDS)[number]))
|
const items = PRIMARY_NAV.filter((item) => MOBILE_IDS.includes(item.id as (typeof MOBILE_IDS)[number]))
|
||||||
|
|
||||||
return (
|
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) => {
|
{items.map((item) => {
|
||||||
const Icon = ICONS[item.id]
|
const Icon = ICONS[item.id]
|
||||||
const active = isNavActive(pathname, item)
|
const active = isNavActive(pathname, item)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Database,
|
Database,
|
||||||
Network,
|
|
||||||
House,
|
House,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -15,8 +14,7 @@ import { isNavActive, PRIMARY_NAV, SIDEBAR_COLLAPSED, SIDEBAR_EXPANDED } from '.
|
|||||||
const ICONS = {
|
const ICONS = {
|
||||||
home: House,
|
home: House,
|
||||||
assistant: Sparkles,
|
assistant: Sparkles,
|
||||||
'data-map': Network,
|
data: Database,
|
||||||
databases: Database,
|
|
||||||
knowledge: BookOpen,
|
knowledge: BookOpen,
|
||||||
skills: WandSparkles,
|
skills: WandSparkles,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type SelectMenuOption = {
|
|||||||
description?: string
|
description?: string
|
||||||
group?: string
|
group?: string
|
||||||
icon?: LucideIcon
|
icon?: LucideIcon
|
||||||
|
mark?: ReactNode
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +251,11 @@ export function SelectMenu({
|
|||||||
return (
|
return (
|
||||||
<div key={option.value || `opt-${index}`}>
|
<div key={option.value || `opt-${index}`}>
|
||||||
{showGroup ? (
|
{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}
|
{option.group}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -270,7 +275,9 @@ export function SelectMenu({
|
|||||||
isActive ? 'bg-accent-soft' : 'hover:bg-surface-subtle'
|
isActive ? 'bg-accent-soft' : 'hover:bg-surface-subtle'
|
||||||
} ${isSelected ? 'text-accent' : 'text-ink'}`}
|
} ${isSelected ? 'text-accent' : 'text-ink'}`}
|
||||||
>
|
>
|
||||||
{Icon ? (
|
{option.mark ? (
|
||||||
|
<span className="mt-0.5 shrink-0">{option.mark}</span>
|
||||||
|
) : Icon ? (
|
||||||
<span
|
<span
|
||||||
className={`mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${
|
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'
|
isSelected ? 'bg-accent text-white' : 'bg-surface-subtle text-ink-muted'
|
||||||
|
|||||||
@@ -77,6 +77,19 @@ export function refsToDatabases(
|
|||||||
.filter((item) => item.db_name)
|
.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(
|
export function refsToDatabase(
|
||||||
refs: HistoryResourceRef[] | undefined,
|
refs: HistoryResourceRef[] | undefined,
|
||||||
): ChatMessage['attachedDb'] | undefined {
|
): ChatMessage['attachedDb'] | undefined {
|
||||||
@@ -103,6 +116,7 @@ export function collectPinnedResources(messages: readonly ChatMessage[]): {
|
|||||||
database?: ChatMessage['attachedDb']
|
database?: ChatMessage['attachedDb']
|
||||||
databases: NonNullable<ChatMessage['attachedDatabases']>
|
databases: NonNullable<ChatMessage['attachedDatabases']>
|
||||||
knowledge?: ChatMessage['attachedKnowledge']
|
knowledge?: ChatMessage['attachedKnowledge']
|
||||||
|
connectors: NonNullable<ChatMessage['attachedConnectors']>
|
||||||
} {
|
} {
|
||||||
const files = collectTurnInputFiles(messages, [])
|
const files = collectTurnInputFiles(messages, [])
|
||||||
const databases: Array<{ id?: string; db_name: string; db_type: string }> = []
|
const databases: Array<{ id?: string; db_name: string; db_type: string }> = []
|
||||||
@@ -128,11 +142,23 @@ export function collectPinnedResources(messages: readonly ChatMessage[]): {
|
|||||||
const lastHumanWithKnowledge = [...messages]
|
const lastHumanWithKnowledge = [...messages]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((msg) => msg.role === 'human' && msg.attachedKnowledge?.name)
|
.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 {
|
return {
|
||||||
files,
|
files,
|
||||||
database: databases[databases.length - 1],
|
database: databases[databases.length - 1],
|
||||||
databases,
|
databases,
|
||||||
knowledge: lastHumanWithKnowledge?.attachedKnowledge,
|
knowledge: lastHumanWithKnowledge?.attachedKnowledge,
|
||||||
|
connectors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +197,7 @@ export function historyToMessages(
|
|||||||
attachedFiles: refsToFiles(msg.resource_refs),
|
attachedFiles: refsToFiles(msg.resource_refs),
|
||||||
attachedKnowledge: refsToKnowledge(msg.resource_refs),
|
attachedKnowledge: refsToKnowledge(msg.resource_refs),
|
||||||
attachedDatabases: refsToDatabases(msg.resource_refs),
|
attachedDatabases: refsToDatabases(msg.resource_refs),
|
||||||
|
attachedConnectors: refsToConnectors(msg.resource_refs),
|
||||||
attachedDb: refsToDatabase(msg.resource_refs),
|
attachedDb: refsToDatabase(msg.resource_refs),
|
||||||
})
|
})
|
||||||
} else if (role === 'assistant' || role === 'view') {
|
} else if (role === 'assistant' || role === 'view') {
|
||||||
@@ -199,6 +226,9 @@ export function historyToMessages(
|
|||||||
if (!lastHuman.attachedDb) {
|
if (!lastHuman.attachedDb) {
|
||||||
lastHuman.attachedDb = refsToDatabase(latestRun.resources)
|
lastHuman.attachedDb = refsToDatabase(latestRun.resources)
|
||||||
}
|
}
|
||||||
|
if (!lastHuman.attachedConnectors?.length) {
|
||||||
|
lastHuman.attachedConnectors = refsToConnectors(latestRun.resources)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const last = mapped[mapped.length - 1]
|
const last = mapped[mapped.length - 1]
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ describe('stepTypeLabel', () => {
|
|||||||
it('keeps a free-form title that is not a tool slug', () => {
|
it('keeps a free-form title that is not a tool slug', () => {
|
||||||
expect(stepTypeLabel('code_interpreter', 'جمعبندی فروش')).toBe('جمعبندی فروش')
|
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', () => {
|
describe('toolActionName', () => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const ENGINE_LABELS: Record<string, string> = {
|
|||||||
html_interpreter: 'assistant.execution.htmlBuild',
|
html_interpreter: 'assistant.execution.htmlBuild',
|
||||||
shell_interpreter: 'assistant.execution.shellCommand',
|
shell_interpreter: 'assistant.execution.shellCommand',
|
||||||
question: 'assistant.execution.askUser',
|
question: 'assistant.execution.askUser',
|
||||||
|
connector_call: 'assistant.execution.connectorRunning',
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
@@ -27,6 +28,7 @@ const CATEGORY_LABELS: Record<string, string> = {
|
|||||||
html_interpreter: 'assistant.panel.categoryHtml',
|
html_interpreter: 'assistant.panel.categoryHtml',
|
||||||
shell_interpreter: 'assistant.panel.categoryShell',
|
shell_interpreter: 'assistant.panel.categoryShell',
|
||||||
load_skill: 'assistant.panel.categorySkill',
|
load_skill: 'assistant.panel.categorySkill',
|
||||||
|
connector_call: 'assistant.panel.categoryConnector',
|
||||||
}
|
}
|
||||||
|
|
||||||
const DETAIL_LABELS: Record<string, string> = {
|
const DETAIL_LABELS: Record<string, string> = {
|
||||||
@@ -38,9 +40,14 @@ function engineLabelKey(value?: string): string | undefined {
|
|||||||
return key ? ENGINE_LABELS[key] : 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 actionKey = action?.trim() || ''
|
||||||
const titleKey = title?.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)
|
const mappedTitle = engineLabelKey(titleKey)
|
||||||
if (mappedTitle) return t(mappedTitle)
|
if (mappedTitle) return t(mappedTitle)
|
||||||
if (titleKey && titleKey !== actionKey) return titleKey
|
if (titleKey && titleKey !== actionKey) return titleKey
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { kamidsoUrl } from '../../../services/api'
|
|||||||
|
|
||||||
export type ResourceRef = {
|
export type ResourceRef = {
|
||||||
id: string
|
id: string
|
||||||
type: 'file' | 'datasource' | 'knowledge'
|
type: 'file' | 'datasource' | 'knowledge' | 'connector'
|
||||||
name?: string
|
name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,8 @@ import { render, screen } from '@testing-library/react'
|
|||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createHtmlShare } from '../api/reports.api'
|
import { createHtmlShare } from '../api/reports.api'
|
||||||
import { downloadReportPdf } from '../report/downloadReportPdf'
|
|
||||||
import { HtmlPreview } from './HtmlPreview'
|
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', () => ({
|
vi.mock('../api/reports.api', () => ({
|
||||||
createHtmlShare: vi.fn(async () => ({
|
createHtmlShare: vi.fn(async () => ({
|
||||||
id: 'rpt_abc',
|
id: 'rpt_abc',
|
||||||
@@ -24,7 +16,6 @@ const sample = "<html dir='rtl' lang='fa'><body><h1>گزارش</h1></body></html
|
|||||||
|
|
||||||
describe('HtmlPreview', () => {
|
describe('HtmlPreview', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(downloadReportPdf).mockClear()
|
|
||||||
vi.mocked(createHtmlShare).mockClear()
|
vi.mocked(createHtmlShare).mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -33,6 +24,7 @@ describe('HtmlPreview', () => {
|
|||||||
render(<HtmlPreview html={sample} />)
|
render(<HtmlPreview html={sample} />)
|
||||||
|
|
||||||
expect(screen.getByTitle('html-preview')).toBeInTheDocument()
|
expect(screen.getByTitle('html-preview')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'دانلود PDF' })).not.toBeInTheDocument()
|
||||||
await user.click(screen.getByRole('button', { name: 'تمامصفحه' }))
|
await user.click(screen.getByRole('button', { name: 'تمامصفحه' }))
|
||||||
expect(screen.getByRole('dialog', { name: 'گزارش تحلیل' })).toBeInTheDocument()
|
expect(screen.getByRole('dialog', { name: 'گزارش تحلیل' })).toBeInTheDocument()
|
||||||
expect(screen.getByTitle('html-preview-full')).toBeInTheDocument()
|
expect(screen.getByTitle('html-preview-full')).toBeInTheDocument()
|
||||||
@@ -41,13 +33,6 @@ describe('HtmlPreview', () => {
|
|||||||
expect(screen.queryByRole('dialog', { name: 'گزارش تحلیل' })).not.toBeInTheDocument()
|
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 () => {
|
it('creates a public share link and copies it', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
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 { t } from '../../../i18n'
|
||||||
import { useBodyScrollLock, useFocusTrap } from '../../../components/ui/useFocusTrap'
|
import { useBodyScrollLock, useFocusTrap } from '../../../components/ui/useFocusTrap'
|
||||||
import { createHtmlShare } from '../api/reports.api'
|
import { createHtmlShare } from '../api/reports.api'
|
||||||
import { downloadReportPdf, reportTitleFromHtml } from '../report/downloadReportPdf'
|
import { reportTitleFromHtml } from '../report/downloadReportPdf'
|
||||||
|
|
||||||
function ReportFrame({
|
function ReportFrame({
|
||||||
html,
|
html,
|
||||||
@@ -49,7 +49,6 @@ export function HtmlPreview({ html }: { html: string }) {
|
|||||||
const expandLabel = t('assistant.report.expand')
|
const expandLabel = t('assistant.report.expand')
|
||||||
const closeLabel = t('assistant.report.close')
|
const closeLabel = t('assistant.report.close')
|
||||||
const title = t('assistant.report.title')
|
const title = t('assistant.report.title')
|
||||||
const downloadLabel = t('assistant.report.downloadPdf')
|
|
||||||
const shareLabel = t('assistant.report.share')
|
const shareLabel = t('assistant.report.share')
|
||||||
const documentTitle = reportTitleFromHtml(html, title)
|
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) {
|
function toolbar(anchor: ShareAnchor, extra?: ReactNode) {
|
||||||
const shareOpen = shareAnchor === anchor
|
const shareOpen = shareAnchor === anchor
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={actionClass}
|
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 { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { ExecutionStep } from '../types/chat'
|
import type { ExecutionStep } from '../types/chat'
|
||||||
import { ManusStepCard } from '../execution/ManusStepCard'
|
import { ManusStepCard } from '../execution/ManusStepCard'
|
||||||
|
import { groupTimeline, SubagentLane, subagentGroupId } from '../execution/SubagentLane'
|
||||||
import { t } from '../../../i18n'
|
import { t } from '../../../i18n'
|
||||||
|
|
||||||
export function StepTimeline({
|
export function StepTimeline({
|
||||||
@@ -16,10 +17,19 @@ export function StepTimeline({
|
|||||||
defaultCollapsed?: boolean
|
defaultCollapsed?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
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
|
if (!steps.length) return null
|
||||||
|
|
||||||
const runningCount = steps.filter((s) => s.status === 'running').length
|
const leadSteps = steps.filter((step) => !subagentGroupId(step.id))
|
||||||
const doneCount = steps.filter((s) => s.status === 'done' || s.status === 'completed').length
|
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 (
|
return (
|
||||||
<div className="rounded-xl border border-line bg-[#f8f9fc] dark:bg-surface-subtle/30">
|
<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 className="flex items-center gap-2">
|
||||||
<span>{t('assistant.panel.executionSteps')}</span>
|
<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">
|
<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')}` : ''}
|
{runningCount > 0 ? ` · ${t('assistant.execution.processing')}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{collapsed ? <ChevronDown className="h-4 w-4 text-ink-muted" /> : <ChevronUp className="h-4 w-4 text-ink-muted" />}
|
{collapsed ? <ChevronDown className="h-4 w-4 text-ink-muted" /> : <ChevronUp className="h-4 w-4 text-ink-muted" />}
|
||||||
</button>
|
</button>
|
||||||
{!collapsed ? (
|
{!collapsed ? (
|
||||||
<ul className="relative space-y-0.5 border-t border-line px-2 py-2">
|
<ul ref={listRef} className="relative space-y-0.5 border-t border-line px-2 py-2">
|
||||||
{steps.map((step, index) => {
|
{rows.map((row, index) => {
|
||||||
const isLast = index === steps.length - 1
|
const isLast = index === rows.length - 1
|
||||||
|
if (row.type === 'lane') {
|
||||||
|
return (
|
||||||
|
<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 (
|
return (
|
||||||
<li key={step.id} className="relative ps-1">
|
<li
|
||||||
|
key={step.id}
|
||||||
|
className="relative ps-1"
|
||||||
|
data-running={step.status === 'running' ? 'true' : undefined}
|
||||||
|
>
|
||||||
{!isLast ? (
|
{!isLast ? (
|
||||||
<span className="absolute start-[1.15rem] top-9 bottom-0 w-px bg-line" aria-hidden />
|
<span className="absolute start-[1.15rem] top-9 bottom-0 w-px bg-line" aria-hidden />
|
||||||
) : null}
|
) : 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 { 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 { KamidsoArtifact } from '../../../contracts/run'
|
||||||
import type { ChatMessage, ExecutionOutput, ExecutionStep, TaskItem } from '../types/chat'
|
import type { ChatMessage, ExecutionOutput, ExecutionStep, TaskItem } from '../types/chat'
|
||||||
import type { SessionFileSnapshot } from '../session-files/types'
|
import type { SessionFileSnapshot } from '../session-files/types'
|
||||||
@@ -26,6 +26,7 @@ export function ExecutionPanel({
|
|||||||
artifacts,
|
artifacts,
|
||||||
inputFiles = [],
|
inputFiles = [],
|
||||||
attachedKnowledge,
|
attachedKnowledge,
|
||||||
|
attachedConnectors = [],
|
||||||
attachedDb,
|
attachedDb,
|
||||||
loading,
|
loading,
|
||||||
developerMode,
|
developerMode,
|
||||||
@@ -41,6 +42,7 @@ export function ExecutionPanel({
|
|||||||
artifacts?: KamidsoArtifact[]
|
artifacts?: KamidsoArtifact[]
|
||||||
inputFiles?: readonly SessionFileSnapshot[]
|
inputFiles?: readonly SessionFileSnapshot[]
|
||||||
attachedKnowledge?: ChatMessage['attachedKnowledge']
|
attachedKnowledge?: ChatMessage['attachedKnowledge']
|
||||||
|
attachedConnectors?: readonly { id: string; name: string }[]
|
||||||
attachedDb?: ChatMessage['attachedDb']
|
attachedDb?: ChatMessage['attachedDb']
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
developerMode?: boolean
|
developerMode?: boolean
|
||||||
@@ -53,8 +55,9 @@ export function ExecutionPanel({
|
|||||||
const resolvedStepId = activeStepId ?? localStepId ?? steps[steps.length - 1]?.id ?? null
|
const resolvedStepId = activeStepId ?? localStepId ?? steps[steps.length - 1]?.id ?? null
|
||||||
const activeStep = steps.find((s) => s.id === resolvedStepId)
|
const activeStep = steps.find((s) => s.id === resolvedStepId)
|
||||||
const stepOutputs = resolvedStepId ? outputs[resolvedStepId] || [] : []
|
const stepOutputs = resolvedStepId ? outputs[resolvedStepId] || [] : []
|
||||||
|
const visibleOutputs = stepOutputs.filter((output) => output.output_type !== 'html')
|
||||||
const fileCount = inputFiles.length + (artifacts?.length ?? 0)
|
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(() => {
|
useEffect(() => {
|
||||||
if (activeStepId) setLocalStepId(activeStepId)
|
if (activeStepId) setLocalStepId(activeStepId)
|
||||||
@@ -133,6 +136,14 @@ export function ExecutionPanel({
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -151,18 +162,18 @@ export function ExecutionPanel({
|
|||||||
|
|
||||||
{activeStep ? <StepDetailCard key={activeStep.id} step={activeStep} /> : null}
|
{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">
|
<div className="overflow-hidden rounded-xl border border-line bg-panel p-3">
|
||||||
<ExecutionOutputRenderer outputs={stepOutputs} developerMode={developerMode} />
|
<ExecutionOutputRenderer outputs={visibleOutputs} developerMode={developerMode} />
|
||||||
</div>
|
</div>
|
||||||
) : loading ? (
|
) : loading && !stepOutputs.length ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-ink-muted">
|
<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" />
|
<Loader2 className="mb-3 h-8 w-8 animate-spin text-accent" />
|
||||||
<p className="text-sm">{t('assistant.execution.processing')}</p>
|
<p className="text-sm">{t('assistant.execution.processing')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : steps.length ? (
|
) : steps.length && !stepOutputs.length ? (
|
||||||
<p className="text-sm text-ink-muted">{t('assistant.panel.noOutput')}</p>
|
<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>
|
<p className="text-sm text-ink-muted">{t('assistant.panel.noSteps')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function ManusStepCard({
|
|||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
}) {
|
}) {
|
||||||
const label = stepTypeLabel(step.action, step.title)
|
const label = stepTypeLabel(step.action, step.title, step.status)
|
||||||
const category = stepCategoryLabel(step.action)
|
const category = stepCategoryLabel(step.action)
|
||||||
const actionName = toolActionName(step.action)
|
const actionName = toolActionName(step.action)
|
||||||
const Icon = toolIconForStep(step)
|
const Icon = toolIconForStep(step)
|
||||||
@@ -56,6 +56,11 @@ export function ManusStepCard({
|
|||||||
{!compact && actionName ? (
|
{!compact && actionName ? (
|
||||||
<span className="mt-0.5 block truncate font-mono text-[10px] text-ink-muted">{actionName}</span>
|
<span className="mt-0.5 block truncate font-mono text-[10px] text-ink-muted">{actionName}</span>
|
||||||
) : null}
|
) : 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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,32 +1,58 @@
|
|||||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { ExecutionStep } from '../types/chat'
|
import type { ExecutionStep } from '../types/chat'
|
||||||
import { stepCategoryLabel, stepDetailLabel, stepTypeLabel, toolActionName } from '../adapters/stepLabels'
|
import { stepCategoryLabel, stepDetailLabel, stepTypeLabel, toolActionName } from '../adapters/stepLabels'
|
||||||
import { toolIconForStep } from './toolIcons'
|
import { toolIconForStep } from './toolIcons'
|
||||||
import { StatusBadge } from './StatusBadge'
|
import { StatusBadge } from './StatusBadge'
|
||||||
|
import { t } from '../../../i18n'
|
||||||
|
|
||||||
export function StepDetailCard({ step }: { step: ExecutionStep }) {
|
export function StepDetailCard({ step }: { step: ExecutionStep }) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const detailRef = useRef<HTMLDivElement>(null)
|
||||||
const Icon = toolIconForStep(step)
|
const Icon = toolIconForStep(step)
|
||||||
const category = stepCategoryLabel(step.action)
|
const category = stepCategoryLabel(step.action)
|
||||||
const actionName = toolActionName(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 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 (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCollapsed((v) => !v)}
|
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"
|
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 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' : ''}`} />
|
<Icon className={`h-4 w-4 ${step.status === 'running' ? 'animate-spin' : ''}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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 ? (
|
{actionName ? (
|
||||||
<span className="rounded bg-surface-subtle px-1.5 py-0.5 font-mono text-[10px] text-ink-muted">
|
<span className="rounded bg-surface-subtle px-1.5 py-0.5 font-mono text-[10px] text-ink-muted">
|
||||||
{actionName}
|
{actionName}
|
||||||
@@ -46,7 +72,10 @@ export function StepDetailCard({ step }: { step: ExecutionStep }) {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{!collapsed && detail ? (
|
{!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}
|
{detail}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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,
|
FileSpreadsheet,
|
||||||
Loader2,
|
Loader2,
|
||||||
Search,
|
Search,
|
||||||
|
Plug,
|
||||||
Terminal,
|
Terminal,
|
||||||
Wrench,
|
Wrench,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -19,6 +20,7 @@ const ACTION_ICONS: Record<string, LucideIcon> = {
|
|||||||
file_reader: FileSearch,
|
file_reader: FileSearch,
|
||||||
sql_executor: Database,
|
sql_executor: Database,
|
||||||
knowledge_search: Search,
|
knowledge_search: Search,
|
||||||
|
connector_call: Plug,
|
||||||
read_file: FileSearch,
|
read_file: FileSearch,
|
||||||
write_file: FileSpreadsheet,
|
write_file: FileSpreadsheet,
|
||||||
run_python: Code2,
|
run_python: Code2,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Paperclip, Send, Square } from 'lucide-react'
|
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 { t } from '../../../i18n'
|
||||||
import AttachmentRail from '../session-files/AttachmentRail'
|
import AttachmentRail from '../session-files/AttachmentRail'
|
||||||
import type { DraftFile, LegacyServerFile } from '../session-files'
|
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 { DATA_FILE_ACCEPT } from '../session-files/fileKind'
|
||||||
import type { ResourceOption } from '../types/chat'
|
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({
|
export function EnhancedChatInput({
|
||||||
query,
|
query,
|
||||||
onQueryChange,
|
onQueryChange,
|
||||||
@@ -19,29 +40,14 @@ export function EnhancedChatInput({
|
|||||||
onRetryFile,
|
onRetryFile,
|
||||||
onAddFiles,
|
onAddFiles,
|
||||||
pinnedDatabases = [],
|
pinnedDatabases = [],
|
||||||
|
pinnedConnectors = [],
|
||||||
pinnedKnowledge = null,
|
pinnedKnowledge = null,
|
||||||
onRemoveDatabase,
|
onRemoveDatabase,
|
||||||
|
onRemoveConnector,
|
||||||
onClearKnowledge,
|
onClearKnowledge,
|
||||||
toolbar,
|
toolbar,
|
||||||
variant = 'dock',
|
variant = 'dock',
|
||||||
}: {
|
}: 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[]
|
|
||||||
pinnedKnowledge?: ResourceOption | null
|
|
||||||
onRemoveDatabase?: (id: string) => void
|
|
||||||
onClearKnowledge?: () => void
|
|
||||||
toolbar?: React.ReactNode
|
|
||||||
variant?: 'hero' | 'dock'
|
|
||||||
}) {
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const [focused, setFocused] = useState(false)
|
const [focused, setFocused] = useState(false)
|
||||||
@@ -51,6 +57,7 @@ export function EnhancedChatInput({
|
|||||||
drafts.length ||
|
drafts.length ||
|
||||||
(legacyFiles?.length ?? 0) ||
|
(legacyFiles?.length ?? 0) ||
|
||||||
pinnedDatabases.length ||
|
pinnedDatabases.length ||
|
||||||
|
pinnedConnectors.length ||
|
||||||
pinnedKnowledge,
|
pinnedKnowledge,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,10 +90,12 @@ export function EnhancedChatInput({
|
|||||||
drafts={drafts}
|
drafts={drafts}
|
||||||
legacyFiles={legacyFiles}
|
legacyFiles={legacyFiles}
|
||||||
databases={pinnedDatabases}
|
databases={pinnedDatabases}
|
||||||
|
connectors={pinnedConnectors}
|
||||||
knowledge={pinnedKnowledge}
|
knowledge={pinnedKnowledge}
|
||||||
onRemove={onRemoveFile}
|
onRemove={onRemoveFile}
|
||||||
onRetry={onRetryFile}
|
onRetry={onRetryFile}
|
||||||
onRemoveDatabase={onRemoveDatabase}
|
onRemoveDatabase={onRemoveDatabase}
|
||||||
|
onRemoveConnector={onRemoveConnector}
|
||||||
onRemoveKnowledge={onClearKnowledge}
|
onRemoveKnowledge={onClearKnowledge}
|
||||||
addControl={null}
|
addControl={null}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -101,4 +101,29 @@ describe('ResourcePicker', () => {
|
|||||||
expect(onToggle).toHaveBeenCalledWith(sales)
|
expect(onToggle).toHaveBeenCalledWith(sales)
|
||||||
expect(screen.getByRole('listbox', { name: 'داده' })).toBeInTheDocument()
|
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' },
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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 { 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 type { ResourceOption } from '../types/chat'
|
||||||
import { ToolbarChipIcon, toolbarChipClass } from './toolbarChip'
|
import { ToolbarChipIcon, toolbarChipClass } from './toolbarChip'
|
||||||
import type { KamidsoFile } from '../session-files'
|
import type { KamidsoFile } from '../session-files'
|
||||||
import { fileKindLine, isDataFileKind } from '../session-files/fileKind'
|
import { fileKindLine, isDataFileKind } from '../session-files/fileKind'
|
||||||
|
|
||||||
const CONN_PREFIX = 'conn:'
|
const DB_PREFIX = 'conn:'
|
||||||
const FILE_PREFIX = 'file:'
|
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({
|
export function ResourcePicker({
|
||||||
databases,
|
databases,
|
||||||
@@ -20,6 +35,9 @@ export function ResourcePicker({
|
|||||||
attachedFileIds = [],
|
attachedFileIds = [],
|
||||||
onToggleLibraryFile,
|
onToggleLibraryFile,
|
||||||
onUploadNew,
|
onUploadNew,
|
||||||
|
connectors = [],
|
||||||
|
selectedConnectors = [],
|
||||||
|
onSelectConnectors,
|
||||||
}: {
|
}: {
|
||||||
databases: ResourceOption[]
|
databases: ResourceOption[]
|
||||||
knowledge: ResourceOption[]
|
knowledge: ResourceOption[]
|
||||||
@@ -31,21 +49,87 @@ export function ResourcePicker({
|
|||||||
attachedFileIds?: readonly string[]
|
attachedFileIds?: readonly string[]
|
||||||
onToggleLibraryFile?: (file: KamidsoFile) => void
|
onToggleLibraryFile?: (file: KamidsoFile) => void
|
||||||
onUploadNew?: () => void
|
onUploadNew?: () => void
|
||||||
|
connectors?: ResourceOption[]
|
||||||
|
selectedConnectors?: ResourceOption[]
|
||||||
|
onSelectConnectors?: (items: ResourceOption[]) => void
|
||||||
}) {
|
}) {
|
||||||
const dataFiles = libraryFiles.filter((file) => isDataFileKind(file.kind))
|
const dataFiles = libraryFiles.filter((file) => isDataFileKind(file.kind))
|
||||||
const attached = dataFiles.filter((file) => attachedFileIds.includes(file.id))
|
const attached = dataFiles.filter((file) => attachedFileIds.includes(file.id))
|
||||||
const names = [
|
const names = [
|
||||||
...selectedDbs.map((item) => item.name),
|
|
||||||
...attached.map((file) => file.title?.trim() || file.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 dataActive = names.length > 0
|
||||||
const selectedDataValues = dataActive
|
const selectedDataValues = dataActive
|
||||||
? [
|
? [
|
||||||
...selectedDbs.map((item) => `${CONN_PREFIX}${item.id}`),
|
|
||||||
...attached.map((file) => `${FILE_PREFIX}${file.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 (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
@@ -57,43 +141,12 @@ export function ResourcePicker({
|
|||||||
displayLabel={t('assistant.composer.data')}
|
displayLabel={t('assistant.composer.data')}
|
||||||
placeholder={t('assistant.composer.data')}
|
placeholder={t('assistant.composer.data')}
|
||||||
closeOnSelect={false}
|
closeOnSelect={false}
|
||||||
options={[
|
options={dataOptions}
|
||||||
{ value: '', label: t('assistant.composer.dataNone') },
|
onChange={onDataChange}
|
||||||
...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)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
leading={
|
leading={
|
||||||
<ToolbarChipIcon
|
<ToolbarChipIcon
|
||||||
icon={attached.length ? FileSpreadsheet : Database}
|
icon={onlyFiles ? FileSpreadsheet : onlyApps ? Plug : Database}
|
||||||
tone={attached.length ? 'files' : selectedDbs.length ? 'database' : 'muted'}
|
tone={onlyFiles ? 'files' : onlyApps ? 'apps' : selectedDbs.length ? 'database' : dataActive ? 'files' : 'muted'}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
trailing={
|
trailing={
|
||||||
@@ -103,19 +156,32 @@ export function ResourcePicker({
|
|||||||
</span>
|
</span>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
minMenuWidth={280}
|
minMenuWidth={300}
|
||||||
emptyLabel={t('assistant.composer.dataEmpty')}
|
emptyLabel={t('assistant.composer.dataEmpty')}
|
||||||
triggerClassName={toolbarChipClass(dataActive)}
|
triggerClassName={toolbarChipClass(dataActive)}
|
||||||
footer={
|
footer={
|
||||||
onUploadNew ? (
|
onUploadNew || dataActive ? (
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
type="button"
|
{onUploadNew ? (
|
||||||
onClick={onUploadNew}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={onUploadNew}
|
||||||
<Upload className="h-3.5 w-3.5" />
|
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"
|
||||||
{t('assistant.composer.uploadData')}
|
>
|
||||||
</button>
|
<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
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export function ToolsBar({
|
|||||||
attachedFileIds = [],
|
attachedFileIds = [],
|
||||||
onToggleLibraryFile,
|
onToggleLibraryFile,
|
||||||
onUploadNew,
|
onUploadNew,
|
||||||
|
connectors = [],
|
||||||
|
selectedConnectors = [],
|
||||||
|
onSelectConnectors,
|
||||||
}: {
|
}: {
|
||||||
databases: ResourceOption[]
|
databases: ResourceOption[]
|
||||||
knowledge: ResourceOption[]
|
knowledge: ResourceOption[]
|
||||||
@@ -42,6 +45,9 @@ export function ToolsBar({
|
|||||||
attachedFileIds?: readonly string[]
|
attachedFileIds?: readonly string[]
|
||||||
onToggleLibraryFile?: (file: KamidsoFile) => void
|
onToggleLibraryFile?: (file: KamidsoFile) => void
|
||||||
onUploadNew?: () => void
|
onUploadNew?: () => void
|
||||||
|
connectors?: ResourceOption[]
|
||||||
|
selectedConnectors?: ResourceOption[]
|
||||||
|
onSelectConnectors?: (items: ResourceOption[]) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-wrap items-center gap-1.5">
|
<div className="flex w-full flex-wrap items-center gap-1.5">
|
||||||
@@ -56,6 +62,9 @@ export function ToolsBar({
|
|||||||
attachedFileIds={attachedFileIds}
|
attachedFileIds={attachedFileIds}
|
||||||
onToggleLibraryFile={onToggleLibraryFile}
|
onToggleLibraryFile={onToggleLibraryFile}
|
||||||
onUploadNew={onUploadNew}
|
onUploadNew={onUploadNew}
|
||||||
|
connectors={connectors}
|
||||||
|
selectedConnectors={selectedConnectors}
|
||||||
|
onSelectConnectors={onSelectConnectors}
|
||||||
/>
|
/>
|
||||||
<SkillSelector skills={skills} value={selectedSkill} onChange={onSelectSkill} />
|
<SkillSelector skills={skills} value={selectedSkill} onChange={onSelectSkill} />
|
||||||
{onReportModeChange ? (
|
{onReportModeChange ? (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const TONE = {
|
|||||||
files: 'bg-accent-soft text-accent',
|
files: 'bg-accent-soft text-accent',
|
||||||
knowledge: 'bg-warn-soft text-warn',
|
knowledge: 'bg-warn-soft text-warn',
|
||||||
skill: 'bg-info-soft text-info',
|
skill: 'bg-info-soft text-info',
|
||||||
|
apps: 'bg-violet-50 text-violet-700',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function ToolbarChipIcon({
|
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 { ReactNode } from 'react'
|
||||||
import type { DraftFile, LegacyServerFile } from './types'
|
import type { DraftFile, LegacyServerFile } from './types'
|
||||||
import { formatBytes, toLegacyRailItem, toRailItems } from './attachment-view-model'
|
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 RailDatabase = { id: string; name: string; type?: string }
|
||||||
export type RailKnowledge = { id: string; name: string }
|
export type RailKnowledge = { id: string; name: string }
|
||||||
|
export type RailConnector = { id: string; name: string }
|
||||||
|
|
||||||
export interface AttachmentRailProps {
|
export interface AttachmentRailProps {
|
||||||
drafts: readonly DraftFile[]
|
drafts: readonly DraftFile[]
|
||||||
legacyFiles?: readonly LegacyServerFile[]
|
legacyFiles?: readonly LegacyServerFile[]
|
||||||
databases?: readonly RailDatabase[]
|
databases?: readonly RailDatabase[]
|
||||||
|
connectors?: readonly RailConnector[]
|
||||||
knowledge?: RailKnowledge | null
|
knowledge?: RailKnowledge | null
|
||||||
onRemove?: (clientId: string) => void
|
onRemove?: (clientId: string) => void
|
||||||
onRetry?: (clientId: string) => void
|
onRetry?: (clientId: string) => void
|
||||||
onRemoveDatabase?: (id: string) => void
|
onRemoveDatabase?: (id: string) => void
|
||||||
|
onRemoveConnector?: (id: string) => void
|
||||||
onRemoveKnowledge?: () => void
|
onRemoveKnowledge?: () => void
|
||||||
addControl?: ReactNode
|
addControl?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
@@ -37,10 +40,12 @@ export default function AttachmentRail({
|
|||||||
drafts,
|
drafts,
|
||||||
legacyFiles = [],
|
legacyFiles = [],
|
||||||
databases = [],
|
databases = [],
|
||||||
|
connectors = [],
|
||||||
knowledge = null,
|
knowledge = null,
|
||||||
onRemove,
|
onRemove,
|
||||||
onRetry,
|
onRetry,
|
||||||
onRemoveDatabase,
|
onRemoveDatabase,
|
||||||
|
onRemoveConnector,
|
||||||
onRemoveKnowledge,
|
onRemoveKnowledge,
|
||||||
addControl,
|
addControl,
|
||||||
className = '',
|
className = '',
|
||||||
@@ -52,6 +57,7 @@ export default function AttachmentRail({
|
|||||||
draftItems.length === 0 &&
|
draftItems.length === 0 &&
|
||||||
legacyItems.length === 0 &&
|
legacyItems.length === 0 &&
|
||||||
databases.length === 0 &&
|
databases.length === 0 &&
|
||||||
|
connectors.length === 0 &&
|
||||||
!hasKnowledge &&
|
!hasKnowledge &&
|
||||||
!addControl
|
!addControl
|
||||||
) {
|
) {
|
||||||
@@ -84,6 +90,30 @@ export default function AttachmentRail({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 ? (
|
{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">
|
<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">
|
<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,
|
downloadFilesFromArtifacts,
|
||||||
mergeDownloadFiles,
|
mergeDownloadFiles,
|
||||||
seedFileOutputs,
|
seedFileOutputs,
|
||||||
|
seedTurnOutputs,
|
||||||
} from './downloadFiles'
|
} from './downloadFiles'
|
||||||
|
|
||||||
describe('downloadFiles helpers', () => {
|
describe('downloadFiles helpers', () => {
|
||||||
@@ -32,6 +33,30 @@ describe('downloadFiles helpers', () => {
|
|||||||
expect(outputs.__exports[0].output_type).toBe('file')
|
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', () => {
|
it('merges unique download lists', () => {
|
||||||
const merged = mergeDownloadFiles(
|
const merged = mergeDownloadFiles(
|
||||||
[{ url: '/files/a.xlsx', name: 'a.xlsx' }],
|
[{ url: '/files/a.xlsx', name: 'a.xlsx' }],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { KamidsoArtifact } from '../../../contracts/run'
|
import type { KamidsoArtifact } from '../../../contracts/run'
|
||||||
import type { DownloadableFile } from '../artifacts/FileDownloadCard'
|
import type { DownloadableFile } from '../artifacts/FileDownloadCard'
|
||||||
import { parseDownloadableFile } from '../artifacts/FileDownloadCard'
|
import { parseDownloadableFile } from '../artifacts/FileDownloadCard'
|
||||||
|
import { extractHtmlFromArtifacts } from '../report/extractReport'
|
||||||
import type { ExecutionOutput } from '../state/runState'
|
import type { ExecutionOutput } from '../state/runState'
|
||||||
|
|
||||||
/** Collect downloadable file outputs from a turn's execution steps. */
|
/** 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('در حال ساخت جدول')
|
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', () => {
|
it('stores html artifacts for the chat iframe', () => {
|
||||||
let state = runReducer(initialRunState(), {
|
let state = runReducer(initialRunState(), {
|
||||||
type: 'RUN_CREATED',
|
type: 'RUN_CREATED',
|
||||||
@@ -219,4 +249,61 @@ describe('runReducer', () => {
|
|||||||
})
|
})
|
||||||
expect(state.htmlByViewId.v1).toBe(html)
|
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 { KamidsoArtifact, PlanTask } from '../../../contracts/run'
|
||||||
|
import type { ExecutionStep } from '../types/chat'
|
||||||
import { emptyExecution, initialRunState, type ExecutionState, type RunState } from '../state/runState'
|
import { emptyExecution, initialRunState, type ExecutionState, type RunState } from '../state/runState'
|
||||||
import { normalizeKamidsoEvent } from './normalizeEvent'
|
import { normalizeKamidsoEvent } from './normalizeEvent'
|
||||||
|
|
||||||
@@ -228,6 +229,32 @@ function applyEvent(
|
|||||||
return { ...next, status: 'completed', loading: false, plan: [], question: null }
|
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 exec = next.executionByViewId[viewId] ?? emptyExecution()
|
||||||
const updatedExec = applyExecutionEvent(exec, event)
|
const updatedExec = applyExecutionEvent(exec, event)
|
||||||
if (updatedExec !== exec) {
|
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 {
|
function applyExecutionEvent(exec: ExecutionState, event: Record<string, unknown>): ExecutionState {
|
||||||
const type = String(event.type ?? '')
|
const type = String(event.type ?? '')
|
||||||
if (type === 'step.start') {
|
if (type === 'step.start') {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { ChatMessage, ExecutionStep } from '../types/chat'
|
|||||||
import { initialRunState } from '../state/runState'
|
import { initialRunState } from '../state/runState'
|
||||||
import { historyToMessages } from '../adapters/historyToMessages'
|
import { historyToMessages } from '../adapters/historyToMessages'
|
||||||
import { runReducer } from './eventReducer'
|
import { runReducer } from './eventReducer'
|
||||||
import { seedFileOutputs, downloadFilesFromArtifacts } from './downloadFiles'
|
import { seedTurnOutputs, downloadFilesFromArtifacts } from './downloadFiles'
|
||||||
|
|
||||||
const ACTIVE_RUN_KEY = 'kamidso.activeRunByConv'
|
const ACTIVE_RUN_KEY = 'kamidso.activeRunByConv'
|
||||||
const STREAM_TERMINAL_TYPES = new Set([
|
const STREAM_TERMINAL_TYPES = new Set([
|
||||||
@@ -70,7 +70,7 @@ type StreamSendParams = {
|
|||||||
skillId?: string
|
skillId?: string
|
||||||
reasoningMode?: 'fast' | 'medium' | 'deep'
|
reasoningMode?: 'fast' | 'medium' | 'deep'
|
||||||
reportMode?: boolean
|
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
|
humanMsg: ChatMessage
|
||||||
viewMsg: ChatMessage
|
viewMsg: ChatMessage
|
||||||
}
|
}
|
||||||
@@ -341,7 +341,7 @@ export function useRunStream() {
|
|||||||
}))
|
}))
|
||||||
const running = [...steps].reverse().find((item) => item.status === 'running')
|
const running = [...steps].reverse().find((item) => item.status === 'running')
|
||||||
const artifacts = (run.artifacts ?? []) as KamidsoArtifact[]
|
const artifacts = (run.artifacts ?? []) as KamidsoArtifact[]
|
||||||
const seededOutputs = seedFileOutputs(artifacts)
|
const { outputs: seededOutputs, html: htmlReport } = seedTurnOutputs(artifacts)
|
||||||
const downloadFiles = downloadFilesFromArtifacts(artifacts)
|
const downloadFiles = downloadFilesFromArtifacts(artifacts)
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'HYDRATE_RUN',
|
type: 'HYDRATE_RUN',
|
||||||
@@ -355,6 +355,7 @@ export function useRunStream() {
|
|||||||
activeViewId: setActive ? viewId : undefined,
|
activeViewId: setActive ? viewId : undefined,
|
||||||
loading: setActive ? live : undefined,
|
loading: setActive ? live : undefined,
|
||||||
lastEventSequence: setActive ? 0 : undefined,
|
lastEventSequence: setActive ? 0 : undefined,
|
||||||
|
htmlByViewId: htmlReport ? { [viewId]: htmlReport } : undefined,
|
||||||
executionByViewId: {
|
executionByViewId: {
|
||||||
[viewId]: {
|
[viewId]: {
|
||||||
steps,
|
steps,
|
||||||
@@ -417,7 +418,7 @@ export function useRunStream() {
|
|||||||
dispatch({ type: 'SET_LOADING', loading: false })
|
dispatch({ type: 'SET_LOADING', loading: false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!live && downloadFiles.length === 0) {
|
} else if (!live && (downloadFiles.length === 0 || !htmlReport)) {
|
||||||
try {
|
try {
|
||||||
const response = await streamRunEvents(runId, 0)
|
const response = await streamRunEvents(runId, 0)
|
||||||
await readStream(response, viewId)
|
await readStream(response, viewId)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export type ChatMessage = {
|
|||||||
context: string
|
context: string
|
||||||
order?: number
|
order?: number
|
||||||
thinking?: boolean
|
thinking?: boolean
|
||||||
|
/** This turn was sent with the report toggle on. */
|
||||||
|
reportMode?: boolean
|
||||||
stopped?: boolean
|
stopped?: boolean
|
||||||
canRetry?: boolean
|
canRetry?: boolean
|
||||||
thought?: string
|
thought?: string
|
||||||
@@ -48,6 +50,7 @@ export type ChatMessage = {
|
|||||||
attachedDatabases?: readonly { id?: string; db_name: string; db_type: string }[]
|
attachedDatabases?: readonly { id?: string; db_name: string; db_type: string }[]
|
||||||
attachedSkill?: { name: string; id: string }
|
attachedSkill?: { name: string; id: string }
|
||||||
attachedKnowledge?: { name: string; id: string }
|
attachedKnowledge?: { name: string; id: string }
|
||||||
|
attachedConnectors?: readonly { id: string; name: string }[]
|
||||||
taskPlan?: TaskItem[]
|
taskPlan?: TaskItem[]
|
||||||
citations?: AgentCitation[]
|
citations?: AgentCitation[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,5 +27,33 @@ describe('ChatMessageList', () => {
|
|||||||
expect(screen.getByText('محاسبه مقادیر مشاهدهشده ارزش افزوده با Python')).toBeInTheDocument()
|
expect(screen.getByText('محاسبه مقادیر مشاهدهشده ارزش افزوده با Python')).toBeInTheDocument()
|
||||||
expect(screen.getByText('تولید گزارش نتیجه با html_interpreter')).toBeInTheDocument()
|
expect(screen.getByText('تولید گزارش نتیجه با html_interpreter')).toBeInTheDocument()
|
||||||
expect(screen.getByText('1/3')).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() ||
|
view?.thought?.trim() ||
|
||||||
(isActiveTurn ? thinkingByViewId?.[viewId ?? '']?.trim() || undefined : undefined)
|
(isActiveTurn ? thinkingByViewId?.[viewId ?? '']?.trim() || undefined : undefined)
|
||||||
const reportLoading = Boolean(
|
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(
|
const isWorking = Boolean(
|
||||||
!view?.stopped && (view?.thinking || (isActiveTurn && loading && !view?.context?.trim())),
|
!view?.stopped && (view?.thinking || (isActiveTurn && loading && !view?.context?.trim())),
|
||||||
@@ -102,6 +108,7 @@ export function ChatMessageList({
|
|||||||
assistantMessage={view?.context}
|
assistantMessage={view?.context}
|
||||||
attachedFiles={human.attachedFiles}
|
attachedFiles={human.attachedFiles}
|
||||||
attachedKnowledge={human.attachedKnowledge}
|
attachedKnowledge={human.attachedKnowledge}
|
||||||
|
attachedConnectors={human.attachedConnectors}
|
||||||
attachedDb={human.attachedDb}
|
attachedDb={human.attachedDb}
|
||||||
hideAttachments={hideTurnAttachments}
|
hideAttachments={hideTurnAttachments}
|
||||||
steps={steps}
|
steps={steps}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
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 { t } from '../../../i18n'
|
||||||
import type { SessionFileSnapshot } from '../session-files/types'
|
import type { SessionFileSnapshot } from '../session-files/types'
|
||||||
import type { ChatMessage } from '../types/chat'
|
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({
|
export function ConversationContextBar({
|
||||||
files,
|
files,
|
||||||
knowledge,
|
knowledge,
|
||||||
databases = [],
|
databases = [],
|
||||||
|
connectors = [],
|
||||||
onPreviewFile,
|
onPreviewFile,
|
||||||
onRemoveFile,
|
onRemoveFile,
|
||||||
onRemoveDatabase,
|
onRemoveDatabase,
|
||||||
|
onRemoveConnector,
|
||||||
onClearKnowledge,
|
onClearKnowledge,
|
||||||
}: {
|
}: ConversationContextBarProps) {
|
||||||
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
|
|
||||||
}) {
|
|
||||||
const fileList = files ?? []
|
const fileList = files ?? []
|
||||||
const hasFiles = fileList.length > 0
|
const hasFiles = fileList.length > 0
|
||||||
const hasKnowledge = Boolean(knowledge?.name)
|
const hasKnowledge = Boolean(knowledge?.name)
|
||||||
const hasDb = databases.length > 0
|
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)
|
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 canToggle = sourceCount > 4
|
||||||
const showChips = expanded || !canToggle
|
const showChips = expanded || !canToggle
|
||||||
const previewNames = [
|
const previewNames = [
|
||||||
...(hasKnowledge && knowledge?.name ? [knowledge.name] : []),
|
...(hasKnowledge && knowledge?.name ? [knowledge.name] : []),
|
||||||
...databases.map((item) => item.db_name),
|
...databases.map((item) => item.db_name),
|
||||||
|
...connectors.map((item) => item.name),
|
||||||
...fileList.map((file) => file.name),
|
...fileList.map((file) => file.name),
|
||||||
]
|
]
|
||||||
const preview = previewNames.slice(0, 3).join('، ')
|
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) => {
|
{fileList.map((file) => {
|
||||||
const Icon = fileIcon(file.name)
|
const Icon = fileIcon(file.name)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
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 AttachmentMessageCards from '../session-files/AttachmentMessageCards'
|
||||||
import type { SessionFileSnapshot } from '../session-files/types'
|
import type { SessionFileSnapshot } from '../session-files/types'
|
||||||
import type { ChatMessage, ExecutionStep, TaskItem } from '../types/chat'
|
import type { ChatMessage, ExecutionStep, TaskItem } from '../types/chat'
|
||||||
@@ -237,17 +237,20 @@ function TurnAttachments({
|
|||||||
files,
|
files,
|
||||||
knowledge,
|
knowledge,
|
||||||
database,
|
database,
|
||||||
|
connectors = [],
|
||||||
onPreviewFile,
|
onPreviewFile,
|
||||||
}: {
|
}: {
|
||||||
files?: readonly SessionFileSnapshot[]
|
files?: readonly SessionFileSnapshot[]
|
||||||
knowledge?: ChatMessage['attachedKnowledge']
|
knowledge?: ChatMessage['attachedKnowledge']
|
||||||
database?: ChatMessage['attachedDb']
|
database?: ChatMessage['attachedDb']
|
||||||
|
connectors?: readonly { id: string; name: string }[]
|
||||||
onPreviewFile?: (file: SessionFileSnapshot) => void
|
onPreviewFile?: (file: SessionFileSnapshot) => void
|
||||||
}) {
|
}) {
|
||||||
const hasFiles = Boolean(files?.length)
|
const hasFiles = Boolean(files?.length)
|
||||||
const hasKnowledge = Boolean(knowledge?.name)
|
const hasKnowledge = Boolean(knowledge?.name)
|
||||||
const hasDb = Boolean(database?.db_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 (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{hasKnowledge ? (
|
{hasKnowledge ? (
|
||||||
@@ -266,6 +269,15 @@ function TurnAttachments({
|
|||||||
tone="bg-emerald-50 text-emerald-700"
|
tone="bg-emerald-50 text-emerald-700"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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}
|
{hasFiles ? <AttachmentMessageCards files={files!} onPreview={onPreviewFile} /> : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -276,6 +288,7 @@ export function SessionTurn({
|
|||||||
assistantMessage,
|
assistantMessage,
|
||||||
attachedFiles,
|
attachedFiles,
|
||||||
attachedKnowledge,
|
attachedKnowledge,
|
||||||
|
attachedConnectors = [],
|
||||||
attachedDb,
|
attachedDb,
|
||||||
steps = [],
|
steps = [],
|
||||||
taskPlan,
|
taskPlan,
|
||||||
@@ -295,6 +308,7 @@ export function SessionTurn({
|
|||||||
assistantMessage?: string
|
assistantMessage?: string
|
||||||
attachedFiles?: readonly SessionFileSnapshot[]
|
attachedFiles?: readonly SessionFileSnapshot[]
|
||||||
attachedKnowledge?: ChatMessage['attachedKnowledge']
|
attachedKnowledge?: ChatMessage['attachedKnowledge']
|
||||||
|
attachedConnectors?: readonly { id: string; name: string }[]
|
||||||
attachedDb?: ChatMessage['attachedDb']
|
attachedDb?: ChatMessage['attachedDb']
|
||||||
steps?: ExecutionStep[]
|
steps?: ExecutionStep[]
|
||||||
taskPlan?: TaskItem[]
|
taskPlan?: TaskItem[]
|
||||||
@@ -321,7 +335,8 @@ export function SessionTurn({
|
|||||||
)
|
)
|
||||||
const stillWorkingAfterReport = isWorking && Boolean(htmlReport) && !hasAnswer
|
const stillWorkingAfterReport = isWorking && Boolean(htmlReport) && !hasAnswer
|
||||||
const hasAttachments =
|
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 hasUser = Boolean(question || hasAttachments)
|
||||||
const hasDownloads = Boolean(downloadFiles?.length)
|
const hasDownloads = Boolean(downloadFiles?.length)
|
||||||
const showAssistant =
|
const showAssistant =
|
||||||
@@ -348,6 +363,7 @@ export function SessionTurn({
|
|||||||
files={attachedFiles}
|
files={attachedFiles}
|
||||||
knowledge={attachedKnowledge}
|
knowledge={attachedKnowledge}
|
||||||
database={attachedDb}
|
database={attachedDb}
|
||||||
|
connectors={attachedConnectors}
|
||||||
onPreviewFile={onPreviewFile}
|
onPreviewFile={onPreviewFile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
icon: FileBarChart,
|
||||||
title: 'فایل داده را تحلیل کنید',
|
title: 'فایل داده را تحلیل کنید',
|
||||||
description:
|
description:
|
||||||
'Excel و CSV را یکبار در پایگاه داده بارگذاری کنید و بعداً در گفتگو انتخاب کنید؛ پیشنمایش، برنامه اجرا و پاسخ مرحلهبهمرحله.',
|
'Excel، CSV و JSON را یکبار در بخش داده بارگذاری کنید و بعداً در گفتگو انتخاب کنید؛ پیشنمایش، برنامه اجرا و پاسخ مرحلهبهمرحله.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
title: 'به پایگاه داده و دانش وصل شوید',
|
title: 'به داده و دانش وصل شوید',
|
||||||
description:
|
description:
|
||||||
'اتصال SQL و فایل جدولی در پایگاه داده است؛ PDF و اسناد در دانش. در گفتگو منبع داده یا دانش را انتخاب کنید.',
|
'فایل، پایگاه داده و اتصال نرمافزار در بخش داده است؛ PDF و اسناد در دانش. در گفتگو منبع داده یا دانش را انتخاب کنید.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Bell,
|
icon: Bell,
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export function SettingsLayout({
|
|||||||
showTabs?: boolean
|
showTabs?: boolean
|
||||||
action?: ReactNode
|
action?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const tabs = [
|
||||||
|
{ to: '/settings', label: 'حساب', end: true },
|
||||||
|
{ to: '/settings/organization', label: 'پروفایل سازمان' },
|
||||||
|
]
|
||||||
return (
|
return (
|
||||||
<PageShell width="md">
|
<PageShell width="md">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -30,10 +34,7 @@ export function SettingsLayout({
|
|||||||
/>
|
/>
|
||||||
{showTabs ? (
|
{showTabs ? (
|
||||||
<FeatureTabs
|
<FeatureTabs
|
||||||
items={[
|
items={tabs}
|
||||||
{ to: '/settings', label: 'حساب', end: true },
|
|
||||||
{ to: '/settings/organization', label: 'پروفایل سازمان' },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
+85
-3
@@ -12,8 +12,10 @@ export const en = {
|
|||||||
data: 'Data',
|
data: 'Data',
|
||||||
dataNone: 'No data source',
|
dataNone: 'No data source',
|
||||||
dataEmpty: 'No data source registered',
|
dataEmpty: 'No data source registered',
|
||||||
dataConnections: 'Connections',
|
dataConnections: 'Databases',
|
||||||
dataFiles: 'Data file',
|
dataFiles: 'Files',
|
||||||
|
dataApps: 'Connections',
|
||||||
|
clearData: 'Clear',
|
||||||
dataConnection: 'SQL connection',
|
dataConnection: 'SQL connection',
|
||||||
uploadData: 'Upload a data file',
|
uploadData: 'Upload a data file',
|
||||||
database: 'Database',
|
database: 'Database',
|
||||||
@@ -25,6 +27,10 @@ export const en = {
|
|||||||
knowledge: 'Knowledge',
|
knowledge: 'Knowledge',
|
||||||
knowledgeNone: 'No knowledge source',
|
knowledgeNone: 'No knowledge source',
|
||||||
knowledgeEmpty: 'No knowledge source registered',
|
knowledgeEmpty: 'No knowledge source registered',
|
||||||
|
connectors: 'Connectors',
|
||||||
|
connectorsNone: 'No business app',
|
||||||
|
connectorsEmpty: 'No business app connected',
|
||||||
|
connectorHint: 'Business app',
|
||||||
skill: 'Skill',
|
skill: 'Skill',
|
||||||
skillDefault: 'Default skill',
|
skillDefault: 'Default skill',
|
||||||
skillEmpty: 'No skill registered',
|
skillEmpty: 'No skill registered',
|
||||||
@@ -45,6 +51,9 @@ export const en = {
|
|||||||
writeReport: 'Write report',
|
writeReport: 'Write report',
|
||||||
shellCommand: 'Shell command',
|
shellCommand: 'Shell command',
|
||||||
askUser: 'Ask user',
|
askUser: 'Ask user',
|
||||||
|
connectorRunning: 'Fetching data from Didar…',
|
||||||
|
connectorDone: 'Didar data received',
|
||||||
|
connectorFailed: 'Could not fetch Didar data',
|
||||||
reactDetail: 'Thought / Action / Observation',
|
reactDetail: 'Thought / Action / Observation',
|
||||||
processing: 'Processing…',
|
processing: 'Processing…',
|
||||||
thinking: 'Thinking…',
|
thinking: 'Thinking…',
|
||||||
@@ -75,7 +84,10 @@ export const en = {
|
|||||||
},
|
},
|
||||||
panel: {
|
panel: {
|
||||||
execution: 'Execution',
|
execution: 'Execution',
|
||||||
|
parallelTasks: 'Parallel tasks',
|
||||||
|
subagent: 'Sub-agent',
|
||||||
categoryReport: 'Report',
|
categoryReport: 'Report',
|
||||||
|
categoryConnector: 'Connector',
|
||||||
categorySkill: 'Skill',
|
categorySkill: 'Skill',
|
||||||
skillLoaded: 'Skill loaded',
|
skillLoaded: 'Skill loaded',
|
||||||
files: 'Files',
|
files: 'Files',
|
||||||
@@ -146,7 +158,7 @@ export const en = {
|
|||||||
sourceUrl: 'URL',
|
sourceUrl: 'URL',
|
||||||
sourceText: 'Text',
|
sourceText: 'Text',
|
||||||
chooseFiles: 'Choose files',
|
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.',
|
libraryEmpty: 'No knowledge documents saved yet.',
|
||||||
url: 'URL',
|
url: 'URL',
|
||||||
textTitle: 'Text title',
|
textTitle: 'Text title',
|
||||||
@@ -287,6 +299,76 @@ export const en = {
|
|||||||
hintMongoPort: 'Default 27017',
|
hintMongoPort: 'Default 27017',
|
||||||
hintMongoAuth: 'Usually admin',
|
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: {
|
dataMap: {
|
||||||
factsTitle: 'Field facts',
|
factsTitle: 'Field facts',
|
||||||
role: 'Role',
|
role: 'Role',
|
||||||
|
|||||||
+85
-3
@@ -12,8 +12,10 @@ export const fa = {
|
|||||||
data: 'داده',
|
data: 'داده',
|
||||||
dataNone: 'بدون منبع داده',
|
dataNone: 'بدون منبع داده',
|
||||||
dataEmpty: 'منبع دادهای ثبت نشده',
|
dataEmpty: 'منبع دادهای ثبت نشده',
|
||||||
dataConnections: 'اتصالها',
|
dataConnections: 'دیتابیس',
|
||||||
dataFiles: 'فایل داده',
|
dataFiles: 'فایلها',
|
||||||
|
dataApps: 'اتصالات',
|
||||||
|
clearData: 'پاک کردن',
|
||||||
dataConnection: 'اتصال SQL',
|
dataConnection: 'اتصال SQL',
|
||||||
uploadData: 'بارگذاری فایل داده',
|
uploadData: 'بارگذاری فایل داده',
|
||||||
database: 'پایگاه داده',
|
database: 'پایگاه داده',
|
||||||
@@ -25,6 +27,10 @@ export const fa = {
|
|||||||
knowledge: 'دانش',
|
knowledge: 'دانش',
|
||||||
knowledgeNone: 'بدون منبع دانش',
|
knowledgeNone: 'بدون منبع دانش',
|
||||||
knowledgeEmpty: 'منبع دانشی ثبت نشده',
|
knowledgeEmpty: 'منبع دانشی ثبت نشده',
|
||||||
|
connectors: 'اتصالها',
|
||||||
|
connectorsNone: 'بدون اتصال نرمافزار',
|
||||||
|
connectorsEmpty: 'نرمافزاری وصل نشده',
|
||||||
|
connectorHint: 'نرمافزار سازمانی',
|
||||||
skill: 'مهارت',
|
skill: 'مهارت',
|
||||||
skillDefault: 'مهارت پیشفرض',
|
skillDefault: 'مهارت پیشفرض',
|
||||||
skillEmpty: 'مهارتی ثبت نشده',
|
skillEmpty: 'مهارتی ثبت نشده',
|
||||||
@@ -45,6 +51,9 @@ export const fa = {
|
|||||||
writeReport: 'نوشتن گزارش',
|
writeReport: 'نوشتن گزارش',
|
||||||
shellCommand: 'فرمان شل',
|
shellCommand: 'فرمان شل',
|
||||||
askUser: 'سؤال از کاربر',
|
askUser: 'سؤال از کاربر',
|
||||||
|
connectorRunning: 'در حال دریافت اطلاعات از دیدار…',
|
||||||
|
connectorDone: 'اطلاعات دیدار دریافت شد',
|
||||||
|
connectorFailed: 'خطا در دریافت اطلاعات از دیدار',
|
||||||
reactDetail: 'تفکر / اقدام / مشاهده',
|
reactDetail: 'تفکر / اقدام / مشاهده',
|
||||||
processing: 'در حال پردازش…',
|
processing: 'در حال پردازش…',
|
||||||
thinking: 'در حال فکر کردن…',
|
thinking: 'در حال فکر کردن…',
|
||||||
@@ -71,6 +80,7 @@ export const fa = {
|
|||||||
categoryReport: 'گزارش',
|
categoryReport: 'گزارش',
|
||||||
categoryShell: 'Shell',
|
categoryShell: 'Shell',
|
||||||
categoryTool: 'ابزار',
|
categoryTool: 'ابزار',
|
||||||
|
categoryConnector: 'اتصال',
|
||||||
categorySkill: 'مهارت',
|
categorySkill: 'مهارت',
|
||||||
skillLoaded: 'مهارت بارگذاریشده',
|
skillLoaded: 'مهارت بارگذاریشده',
|
||||||
statusRunning: 'در حال اجرا',
|
statusRunning: 'در حال اجرا',
|
||||||
@@ -98,6 +108,8 @@ export const fa = {
|
|||||||
summaryPlaceholder: 'پس از اتمام، پاسخ نهایی اینجا ظاهر میشود.',
|
summaryPlaceholder: 'پس از اتمام، پاسخ نهایی اینجا ظاهر میشود.',
|
||||||
showExecution: 'جزئیات اجرا',
|
showExecution: 'جزئیات اجرا',
|
||||||
hide: 'بستن پنل',
|
hide: 'بستن پنل',
|
||||||
|
parallelTasks: 'کارهای موازی',
|
||||||
|
subagent: 'زیرعامل',
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
empty: 'هنوز فایلی در این فضای کاری وجود ندارد.',
|
empty: 'هنوز فایلی در این فضای کاری وجود ندارد.',
|
||||||
@@ -173,7 +185,7 @@ export const fa = {
|
|||||||
sourceUrl: 'نشانی وب',
|
sourceUrl: 'نشانی وب',
|
||||||
sourceText: 'متن',
|
sourceText: 'متن',
|
||||||
chooseFiles: 'انتخاب فایل',
|
chooseFiles: 'انتخاب فایل',
|
||||||
fileHint: 'pdf، Word، متن و markdown پشتیبانی میشوند. Excel و CSV را در پایگاه داده بارگذاری کنید.',
|
fileHint: 'pdf، Word، متن و markdown پشتیبانی میشوند. Excel، CSV و JSON را در بخش داده، تب فایلها بارگذاری کنید.',
|
||||||
libraryEmpty: 'سندی برای دانش ذخیره نشده.',
|
libraryEmpty: 'سندی برای دانش ذخیره نشده.',
|
||||||
url: 'نشانی وب',
|
url: 'نشانی وب',
|
||||||
textTitle: 'عنوان متن',
|
textTitle: 'عنوان متن',
|
||||||
@@ -314,6 +326,76 @@ export const fa = {
|
|||||||
hintMongoPort: 'پیشفرض 27017',
|
hintMongoPort: 'پیشفرض 27017',
|
||||||
hintMongoAuth: 'معمولاً admin',
|
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: {
|
dataMap: {
|
||||||
factsTitle: 'واقعیتهای فیلد',
|
factsTitle: 'واقعیتهای فیلد',
|
||||||
role: 'نقش',
|
role: 'نقش',
|
||||||
|
|||||||
+84
-31
@@ -7,6 +7,7 @@ import { useAssistantRuntime } from '../app/workspace/AssistantRuntimeContext'
|
|||||||
import { ensureConversation } from '../features/assistant/api/conversations.api'
|
import { ensureConversation } from '../features/assistant/api/conversations.api'
|
||||||
import { replyQuestion, rejectQuestion } from '../features/assistant/api/chat.api'
|
import { replyQuestion, rejectQuestion } from '../features/assistant/api/chat.api'
|
||||||
import { listDatasources, listKnowledgeSpaces, listSkills } from '../features/assistant/api/resources.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 { messagesToTurns } from '../features/assistant/adapters/runToTurn'
|
||||||
import { collectPinnedResources, collectTurnInputFiles, humanForView } from '../features/assistant/adapters/historyToMessages'
|
import { collectPinnedResources, collectTurnInputFiles, humanForView } from '../features/assistant/adapters/historyToMessages'
|
||||||
import { ExecutionPanel } from '../features/assistant/execution/ExecutionPanel'
|
import { ExecutionPanel } from '../features/assistant/execution/ExecutionPanel'
|
||||||
@@ -41,7 +42,8 @@ import { skillSettingsPath } from '../features/settings/api'
|
|||||||
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
|
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
|
||||||
|
|
||||||
export function AssistantPage() {
|
export function AssistantPage() {
|
||||||
const { developerMode } = useFeatureFlags()
|
const { developerMode, isEnabled } = useFeatureFlags()
|
||||||
|
const connectorsEnabled = isEnabled('connectors')
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
@@ -50,8 +52,10 @@ export function AssistantPage() {
|
|||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [databases, setDatabases] = useState<ResourceOption[]>([])
|
const [databases, setDatabases] = useState<ResourceOption[]>([])
|
||||||
const [knowledge, setKnowledge] = useState<ResourceOption[]>([])
|
const [knowledge, setKnowledge] = useState<ResourceOption[]>([])
|
||||||
|
const [connectors, setConnectors] = useState<ResourceOption[]>([])
|
||||||
const [skills, setSkills] = useState<ResourceOption[]>([])
|
const [skills, setSkills] = useState<ResourceOption[]>([])
|
||||||
const [selectedDbs, setSelectedDbs] = useState<ResourceOption[]>([])
|
const [selectedDbs, setSelectedDbs] = useState<ResourceOption[]>([])
|
||||||
|
const [selectedConnectors, setSelectedConnectors] = useState<ResourceOption[]>([])
|
||||||
const [selectedKnowledge, setSelectedKnowledge] = useState<ResourceOption | null>(null)
|
const [selectedKnowledge, setSelectedKnowledge] = useState<ResourceOption | null>(null)
|
||||||
const [selectedSkill, setSelectedSkill] = useState<ResourceOption | null>(null)
|
const [selectedSkill, setSelectedSkill] = useState<ResourceOption | null>(null)
|
||||||
const [reasoningMode, setReasoningMode] = useState<'fast' | 'medium' | 'deep'>('fast')
|
const [reasoningMode, setReasoningMode] = useState<'fast' | 'medium' | 'deep'>('fast')
|
||||||
@@ -95,6 +99,27 @@ export function AssistantPage() {
|
|||||||
refreshLibrary()
|
refreshLibrary()
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
const skillId = searchParams.get('skill')
|
const skillId = searchParams.get('skill')
|
||||||
if (!skillId || !skills.length || convId) return
|
if (!skillId || !skills.length || convId) return
|
||||||
@@ -121,6 +146,7 @@ export function AssistantPage() {
|
|||||||
sessionFiles.resetSession()
|
sessionFiles.resetSession()
|
||||||
setConversationId(null)
|
setConversationId(null)
|
||||||
setSelectedDbs([])
|
setSelectedDbs([])
|
||||||
|
setSelectedConnectors([])
|
||||||
setSelectedKnowledge(null)
|
setSelectedKnowledge(null)
|
||||||
setSelectedSkill(null)
|
setSelectedSkill(null)
|
||||||
setSelectedStepId(null)
|
setSelectedStepId(null)
|
||||||
@@ -169,6 +195,7 @@ export function AssistantPage() {
|
|||||||
if (sessionFiles.sessionId !== convId) {
|
if (sessionFiles.sessionId !== convId) {
|
||||||
if (sessionFiles.sessionId) sessionFiles.resetSession()
|
if (sessionFiles.sessionId) sessionFiles.resetSession()
|
||||||
setSelectedDbs([])
|
setSelectedDbs([])
|
||||||
|
setSelectedConnectors([])
|
||||||
setSelectedKnowledge(null)
|
setSelectedKnowledge(null)
|
||||||
setSelectedSkill(null)
|
setSelectedSkill(null)
|
||||||
restoredFilesFor.current = null
|
restoredFilesFor.current = null
|
||||||
@@ -193,7 +220,7 @@ export function AssistantPage() {
|
|||||||
if (pins.files.length) sessionFiles.attachLibraryFiles(pins.files, convId)
|
if (pins.files.length) sessionFiles.attachLibraryFiles(pins.files, convId)
|
||||||
}
|
}
|
||||||
if (restoredCatalogFor.current === convId) return
|
if (restoredCatalogFor.current === convId) return
|
||||||
if (!databases.length && !knowledge.length) return
|
if (!databases.length && !knowledge.length && !connectors.length) return
|
||||||
restoredCatalogFor.current = convId
|
restoredCatalogFor.current = convId
|
||||||
if (pins.databases.length) {
|
if (pins.databases.length) {
|
||||||
const matches = pins.databases
|
const matches = pins.databases
|
||||||
@@ -209,9 +236,13 @@ export function AssistantPage() {
|
|||||||
)
|
)
|
||||||
if (match) setSelectedKnowledge(match)
|
if (match) setSelectedKnowledge(match)
|
||||||
}
|
}
|
||||||
// attachLibraryFiles is stable; avoid depending on the whole sessionFiles object.
|
if (pins.connectors.length) {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
const matches = pins.connectors
|
||||||
}, [convId, databases, knowledge, sessionFiles.attachLibraryFiles, stream.conversationId, stream.messages])
|
.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 rounds = useMemo(() => messagesToTurns(stream.messages), [stream.messages])
|
||||||
const isWelcome = rounds.length === 0
|
const isWelcome = rounds.length === 0
|
||||||
@@ -280,6 +311,7 @@ export function AssistantPage() {
|
|||||||
attachedDb: selectedDbs[0]
|
attachedDb: selectedDbs[0]
|
||||||
? { db_name: selectedDbs[0].name, db_type: selectedDbs[0].type || '' }
|
? { db_name: selectedDbs[0].name, db_type: selectedDbs[0].type || '' }
|
||||||
: activeHuman?.attachedDb,
|
: activeHuman?.attachedDb,
|
||||||
|
attachedConnectors: selectedConnectors.map((item) => ({ id: item.id, name: item.name })),
|
||||||
loading: stream.loading,
|
loading: stream.loading,
|
||||||
developerMode,
|
developerMode,
|
||||||
}
|
}
|
||||||
@@ -312,7 +344,7 @@ export function AssistantPage() {
|
|||||||
sendSnapshot = await sessionFiles.prepare(currentConvId)
|
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) {
|
if (sendSnapshot) {
|
||||||
for (const file of snapshotsForSend(sendSnapshot)) {
|
for (const file of snapshotsForSend(sendSnapshot)) {
|
||||||
resources.push({ id: file.file_id, type: 'file', name: file.name })
|
resources.push({ id: file.file_id, type: 'file', name: file.name })
|
||||||
@@ -321,6 +353,9 @@ export function AssistantPage() {
|
|||||||
for (const item of selectedDbs) {
|
for (const item of selectedDbs) {
|
||||||
resources.push({ id: item.id, type: 'datasource', name: item.name })
|
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 })
|
if (selectedKnowledge) resources.push({ id: selectedKnowledge.id, type: 'knowledge', name: selectedKnowledge.name })
|
||||||
|
|
||||||
const humanId = uuidv4()
|
const humanId = uuidv4()
|
||||||
@@ -358,8 +393,9 @@ export function AssistantPage() {
|
|||||||
attachedKnowledge: selectedKnowledge
|
attachedKnowledge: selectedKnowledge
|
||||||
? { name: selectedKnowledge.name, id: selectedKnowledge.id }
|
? { name: selectedKnowledge.name, id: selectedKnowledge.id }
|
||||||
: undefined,
|
: 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(() => {
|
.finally(() => {
|
||||||
void refreshConversations()
|
void refreshConversations()
|
||||||
@@ -397,7 +433,9 @@ export function AssistantPage() {
|
|||||||
.filter((file): file is SessionFileSnapshot => Boolean(file?.file_id)),
|
.filter((file): file is SessionFileSnapshot => Boolean(file?.file_id)),
|
||||||
[sessionFiles.files],
|
[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 dockDrafts = sessionFiles.files.filter((draft) => draft.upload.status !== 'done')
|
||||||
|
|
||||||
const handleRemovePinnedFile = useCallback(
|
const handleRemovePinnedFile = useCallback(
|
||||||
@@ -464,12 +502,15 @@ export function AssistantPage() {
|
|||||||
attachedFileIds={attachedFileIds}
|
attachedFileIds={attachedFileIds}
|
||||||
onToggleLibraryFile={handleToggleLibraryFile}
|
onToggleLibraryFile={handleToggleLibraryFile}
|
||||||
onUploadNew={() => libraryFileInputRef.current?.click()}
|
onUploadNew={() => libraryFileInputRef.current?.click()}
|
||||||
|
connectors={connectors}
|
||||||
|
selectedConnectors={selectedConnectors}
|
||||||
|
onSelectConnectors={connectorsEnabled ? setSelectedConnectors : undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 min-w-0 overflow-x-hidden" dir="ltr">
|
<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 ? (
|
{isWelcome ? (
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
<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">
|
<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}
|
onAddFiles={handlePickLocalFiles}
|
||||||
pinnedDatabases={selectedDbs}
|
pinnedDatabases={selectedDbs}
|
||||||
pinnedKnowledge={selectedKnowledge}
|
pinnedKnowledge={selectedKnowledge}
|
||||||
|
pinnedConnectors={selectedConnectors}
|
||||||
onRemoveDatabase={(id) => setSelectedDbs((prev) => prev.filter((item) => item.id !== id))}
|
onRemoveDatabase={(id) => setSelectedDbs((prev) => prev.filter((item) => item.id !== id))}
|
||||||
onClearKnowledge={() => setSelectedKnowledge(null)}
|
onClearKnowledge={() => setSelectedKnowledge(null)}
|
||||||
|
onRemoveConnector={(id: string) =>
|
||||||
|
setSelectedConnectors((prev) => prev.filter((item) => item.id !== id))
|
||||||
|
}
|
||||||
toolbar={toolsBar}
|
toolbar={toolsBar}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -505,7 +550,7 @@ export function AssistantPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`grid min-h-0 flex-1 ${
|
className={`grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] ${
|
||||||
showExecutionPanel
|
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)]'
|
? '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'
|
: 'grid-cols-1'
|
||||||
@@ -513,14 +558,15 @@ export function AssistantPage() {
|
|||||||
dir="ltr"
|
dir="ltr"
|
||||||
>
|
>
|
||||||
{showExecutionPanel ? (
|
{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
|
<ExecutionPanel
|
||||||
{...executionPanelProps}
|
{...executionPanelProps}
|
||||||
|
className="h-full min-h-0 w-full"
|
||||||
onCollapse={() => setPanelCollapsed(true)}
|
onCollapse={() => setPanelCollapsed(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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' ? (
|
{stream.runStatus === 'waiting_for_user' ? (
|
||||||
<div className="mb-2 rounded-lg bg-warn-soft px-3 py-2 text-xs text-warn chat-enter">
|
<div className="mb-2 rounded-lg bg-warn-soft px-3 py-2 text-xs text-warn chat-enter">
|
||||||
{t('assistant.execution.waitingForUser')}
|
{t('assistant.execution.waitingForUser')}
|
||||||
@@ -540,11 +586,15 @@ export function AssistantPage() {
|
|||||||
db_name: item.name,
|
db_name: item.name,
|
||||||
db_type: item.type || '',
|
db_type: item.type || '',
|
||||||
}))}
|
}))}
|
||||||
|
connectors={selectedConnectors.map((item) => ({ id: item.id, name: item.name }))}
|
||||||
onPreviewFile={(file) => void handlePreviewFile(file)}
|
onPreviewFile={(file) => void handlePreviewFile(file)}
|
||||||
onRemoveFile={handleRemovePinnedFile}
|
onRemoveFile={handleRemovePinnedFile}
|
||||||
onRemoveDatabase={(key) =>
|
onRemoveDatabase={(key) =>
|
||||||
setSelectedDbs((prev) => prev.filter((item) => item.id !== key && item.name !== 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)}
|
onClearKnowledge={() => setSelectedKnowledge(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -563,27 +613,30 @@ export function AssistantPage() {
|
|||||||
onRetryStopped={() => void stream.retryStopped()}
|
onRetryStopped={() => void stream.retryStopped()}
|
||||||
onPreviewFile={(file) => void handlePreviewFile(file)}
|
onPreviewFile={(file) => void handlePreviewFile(file)}
|
||||||
/>
|
/>
|
||||||
|
<EnhancedChatInput
|
||||||
|
variant="dock"
|
||||||
|
query={query}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
onSend={() => void handleSend()}
|
||||||
|
onStop={() => void stream.cancel()}
|
||||||
|
loading={stream.loading}
|
||||||
|
drafts={dockDrafts}
|
||||||
|
legacyFiles={sessionFiles.legacyFile ? [sessionFiles.legacyFile] : []}
|
||||||
|
onRemoveFile={sessionFiles.remove}
|
||||||
|
onRetryFile={() => sessionFiles.retryFailed()}
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
<EnhancedChatInput
|
|
||||||
variant="dock"
|
|
||||||
query={query}
|
|
||||||
onQueryChange={setQuery}
|
|
||||||
onSend={() => void handleSend()}
|
|
||||||
onStop={() => void stream.cancel()}
|
|
||||||
loading={stream.loading}
|
|
||||||
drafts={dockDrafts}
|
|
||||||
legacyFiles={sessionFiles.legacyFile ? [sessionFiles.legacyFile] : []}
|
|
||||||
onRemoveFile={sessionFiles.remove}
|
|
||||||
onRetryFile={() => sessionFiles.retryFailed()}
|
|
||||||
onAddFiles={handlePickLocalFiles}
|
|
||||||
pinnedDatabases={selectedDbs}
|
|
||||||
pinnedKnowledge={selectedKnowledge}
|
|
||||||
onRemoveDatabase={(id) => setSelectedDbs((prev) => prev.filter((item) => item.id !== id))}
|
|
||||||
onClearKnowledge={() => setSelectedKnowledge(null)}
|
|
||||||
toolbar={toolsBar}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,22 +1,13 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { fetchSharedReport } from '../features/assistant/api/reports.api'
|
import { fetchSharedReport } from '../features/assistant/api/reports.api'
|
||||||
import { downloadReportPdf } from '../features/assistant/report/downloadReportPdf'
|
|
||||||
import { ShareReportPage } from './ShareReportPage'
|
import { ShareReportPage } from './ShareReportPage'
|
||||||
|
|
||||||
vi.mock('../features/assistant/api/reports.api', () => ({
|
vi.mock('../features/assistant/api/reports.api', () => ({
|
||||||
fetchSharedReport: vi.fn(),
|
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') {
|
function renderShare(path = '/share/reports/rpt_abc') {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
@@ -30,7 +21,6 @@ function renderShare(path = '/share/reports/rpt_abc') {
|
|||||||
describe('ShareReportPage', () => {
|
describe('ShareReportPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(fetchSharedReport).mockReset()
|
vi.mocked(fetchSharedReport).mockReset()
|
||||||
vi.mocked(downloadReportPdf).mockClear()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the report full screen without app navigation', async () => {
|
it('shows the report full screen without app navigation', async () => {
|
||||||
@@ -39,18 +29,12 @@ describe('ShareReportPage', () => {
|
|||||||
title: 'فروش',
|
title: 'فروش',
|
||||||
html: "<html dir='rtl'><body><h1>فروش</h1></body></html>",
|
html: "<html dir='rtl'><body><h1>فروش</h1></body></html>",
|
||||||
})
|
})
|
||||||
const user = userEvent.setup()
|
|
||||||
renderShare()
|
renderShare()
|
||||||
|
|
||||||
expect(await screen.findByTitle('html-preview-share')).toBeInTheDocument()
|
expect(await screen.findByTitle('html-preview-share')).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
|
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByRole('button', { name: 'باز کردن منو' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: 'باز کردن منو' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'دانلود PDF' })).not.toBeInTheDocument()
|
||||||
await user.click(screen.getByRole('button', { name: 'دانلود PDF' }))
|
|
||||||
expect(downloadReportPdf).toHaveBeenCalledWith(
|
|
||||||
"<html dir='rtl'><body><h1>فروش</h1></body></html>",
|
|
||||||
'فروش',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows a not-found state without asking for login', async () => {
|
it('shows a not-found state without asking for login', async () => {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { Download } from 'lucide-react'
|
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
import { fetchSharedReport } from '../features/assistant/api/reports.api'
|
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 =
|
type LoadState =
|
||||||
| { status: 'loading' }
|
| { status: 'loading' }
|
||||||
@@ -74,8 +73,6 @@ export function ShareReportPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadLabel = t('assistant.report.downloadPdf')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-dvh w-full overflow-hidden bg-white">
|
<div className="relative h-dvh w-full overflow-hidden bg-white">
|
||||||
<iframe
|
<iframe
|
||||||
@@ -84,14 +81,6 @@ export function ShareReportPage() {
|
|||||||
className="h-full w-full bg-white"
|
className="h-full w-full bg-white"
|
||||||
srcDoc={state.html}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+8
-1
@@ -1,22 +1,29 @@
|
|||||||
/// <reference types="vitest/config" />
|
/// <reference types="vitest/config" />
|
||||||
|
import http from 'node:http'
|
||||||
|
import https from 'node:https'
|
||||||
import { defineConfig, loadEnv, type ProxyOptions } from 'vite'
|
import { defineConfig, loadEnv, type ProxyOptions } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { apiOrigin } from './src/services/apiBase'
|
import { apiOrigin } from './src/services/apiBase'
|
||||||
|
|
||||||
function apiProxy(target: string): ProxyOptions {
|
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 {
|
return {
|
||||||
target,
|
target,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
proxyTimeout: 0,
|
proxyTimeout: 0,
|
||||||
|
agent,
|
||||||
configure(proxy) {
|
configure(proxy) {
|
||||||
proxy.on('proxyRes', (proxyRes, _req, res) => {
|
proxy.on('proxyRes', (proxyRes, _req, res) => {
|
||||||
const type = String(proxyRes.headers['content-type'] || '')
|
const type = String(proxyRes.headers['content-type'] || '')
|
||||||
if (!type.includes('text/event-stream')) return
|
if (!type.includes('text/event-stream')) return
|
||||||
delete proxyRes.headers['content-length']
|
delete proxyRes.headers['content-length']
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
||||||
res.setHeader('Connection', 'keep-alive')
|
|
||||||
res.setHeader('X-Accel-Buffering', 'no')
|
res.setHeader('X-Accel-Buffering', 'no')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user