From e9171be2298536efbe45f539a3af3559a6b8d3fc Mon Sep 17 00:00:00 2001 From: javad Date: Tue, 13 Oct 2026 14:23:07 +0330 Subject: [PATCH] Expand the database catalog and make knowledge spaces easier to start. Co-authored-by: Cursor --- src/components/ui/SegmentedTabs.tsx | 24 +- src/components/ui/SelectMenu.tsx | 65 +++- .../assistant/input/ResourcePicker.test.tsx | 45 +++ .../assistant/input/ResourcePicker.tsx | 5 +- src/features/data/ConnectionsPage.test.tsx | 48 +++ src/features/data/ConnectionsPage.tsx | 269 ++++++++++------ src/features/data/DataDashboardPage.tsx | 297 ++++++++++++++---- src/features/databases/ConnectEngineModal.tsx | 57 +++- src/features/databases/EngineMark.test.tsx | 18 ++ src/features/databases/EngineMark.tsx | 268 +++++++++++++--- src/features/databases/engines.test.ts | 41 +++ src/features/databases/engines.ts | 178 ++++++++--- src/features/knowledge/ChunkBrowser.tsx | 60 ++-- src/features/knowledge/CreateSpaceModal.tsx | 6 +- src/features/knowledge/DocumentTable.tsx | 122 ++++--- src/features/knowledge/KnowledgeDetail.tsx | 130 ++++++-- src/features/knowledge/KnowledgeList.tsx | 269 ++++++++++++---- src/features/knowledge/RecallTestPanel.tsx | 78 +++-- src/i18n/en.ts | 61 +++- src/i18n/fa.ts | 61 +++- 20 files changed, 1619 insertions(+), 483 deletions(-) create mode 100644 src/features/data/ConnectionsPage.test.tsx create mode 100644 src/features/databases/EngineMark.test.tsx create mode 100644 src/features/databases/engines.test.ts diff --git a/src/components/ui/SegmentedTabs.tsx b/src/components/ui/SegmentedTabs.tsx index 7d8a459..03efc1c 100644 --- a/src/components/ui/SegmentedTabs.tsx +++ b/src/components/ui/SegmentedTabs.tsx @@ -1,33 +1,37 @@ +import type { LucideIcon } from 'lucide-react' + export function SegmentedTabs({ items, value, onChange, className = '', }: { - items: ReadonlyArray + items: ReadonlyArray<{ id: T; label: string; icon?: LucideIcon }> value: T onChange: (value: T) => void className?: string }) { return (
- {items.map(([id, label]) => { - const active = value === id + {items.map((item) => { + const Icon = item.icon + const active = value === item.id return ( ) })} diff --git a/src/components/ui/SelectMenu.tsx b/src/components/ui/SelectMenu.tsx index 61a0d9a..e847edb 100644 --- a/src/components/ui/SelectMenu.tsx +++ b/src/components/ui/SelectMenu.tsx @@ -78,6 +78,8 @@ export function SelectMenu({ const [highlight, setHighlight] = useState(-1) const triggerRef = useRef(null) const menuRef = useRef(null) + const listRef = useRef(null) + const scrollRequest = useRef(null) const selected = useMemo( () => options.find((option) => option.value === value) || null, @@ -88,6 +90,12 @@ export function SelectMenu({ () => options.map((option, index) => (option.disabled ? -1 : index)).filter((index) => index >= 0), [options], ) + const optionsRef = useRef(options) + const valueRef = useRef(value) + const enabledRef = useRef(enabledIndexes) + optionsRef.current = options + valueRef.current = value + enabledRef.current = enabledIndexes function updatePosition() { if (!triggerRef.current) return @@ -110,12 +118,33 @@ export function SelectMenu({ }) } + function revealHighlight(index: number) { + const list = listRef.current + const item = list?.querySelector(`[data-index="${index}"]`) + if (!list || !item) return + const listRect = list.getBoundingClientRect() + const itemRect = item.getBoundingClientRect() + if (itemRect.top < listRect.top) list.scrollTop -= listRect.top - itemRect.top + else if (itemRect.bottom > listRect.bottom) list.scrollTop += itemRect.bottom - listRect.bottom + } + + function requestHighlight(index: number) { + scrollRequest.current = index + setHighlight(index) + } + useLayoutEffect(() => { - if (!open) return + if (!open) { + scrollRequest.current = null + return + } updatePosition() - const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled) - setHighlight(selectedIndex >= 0 ? selectedIndex : enabledIndexes[0] ?? -1) - }, [open, options, value, enabledIndexes]) + const selectedIndex = optionsRef.current.findIndex( + (option) => option.value === valueRef.current && !option.disabled, + ) + const enabled = enabledRef.current + requestHighlight(selectedIndex >= 0 ? selectedIndex : enabled[0] ?? -1) + }, [open]) useEffect(() => { if (!open) return @@ -132,7 +161,9 @@ export function SelectMenu({ triggerRef.current?.focus() } } - function onReposition() { + function onReposition(event: Event) { + const target = event.target + if (target instanceof Node && menuRef.current?.contains(target)) return updatePosition() } document.addEventListener('mousedown', onPointerDown) @@ -147,10 +178,10 @@ export function SelectMenu({ } }, [open]) - useEffect(() => { - if (!open || highlight < 0) return - const item = menuRef.current?.querySelector(`[data-index="${highlight}"]`) - item?.scrollIntoView({ block: 'nearest' }) + useLayoutEffect(() => { + if (!open || highlight < 0 || scrollRequest.current !== highlight) return + scrollRequest.current = null + revealHighlight(highlight) }, [highlight, open]) function selectValue(next: string) { @@ -165,13 +196,13 @@ export function SelectMenu({ const currentPos = enabledIndexes.indexOf(highlight) const start = currentPos >= 0 ? currentPos : delta > 0 ? -1 : 0 const nextPos = (start + delta + enabledIndexes.length) % enabledIndexes.length - setHighlight(enabledIndexes[nextPos]!) + requestHighlight(enabledIndexes[nextPos]!) } useEffect(() => { if (!open) return - menuRef.current?.focus() - }, [open, pos]) + menuRef.current?.focus({ preventScroll: true }) + }, [open]) function onTriggerKeyDown(event: ReactKeyboardEvent) { if (disabled) return @@ -194,12 +225,12 @@ export function SelectMenu({ } if (event.key === 'Home') { event.preventDefault() - setHighlight(enabledIndexes[0] ?? -1) + requestHighlight(enabledIndexes[0] ?? -1) return } if (event.key === 'End') { event.preventDefault() - setHighlight(enabledIndexes[enabledIndexes.length - 1] ?? -1) + requestHighlight(enabledIndexes[enabledIndexes.length - 1] ?? -1) return } if (event.key === 'Enter' || event.key === ' ') { @@ -236,7 +267,7 @@ export function SelectMenu({ }} className={`fixed z-[9999] flex flex-col ${PANEL_CLASS}`} > -
+
{options.length === 0 ? (

{emptyLabel}

) : ( @@ -266,7 +297,9 @@ export function SelectMenu({ aria-selected={isSelected} disabled={option.disabled} onMouseEnter={() => { - if (!option.disabled) setHighlight(index) + if (option.disabled) return + scrollRequest.current = null + setHighlight(index) }} onClick={() => { if (!option.disabled) selectValue(option.value) diff --git a/src/features/assistant/input/ResourcePicker.test.tsx b/src/features/assistant/input/ResourcePicker.test.tsx index d5eb2ea..465ffc1 100644 --- a/src/features/assistant/input/ResourcePicker.test.tsx +++ b/src/features/assistant/input/ResourcePicker.test.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' @@ -102,6 +103,50 @@ describe('ResourcePicker', () => { expect(screen.getByRole('listbox', { name: 'داده' })).toBeInTheDocument() }) + it('keeps the file list scrolled when another file is selected', async () => { + const user = userEvent.setup() + function Harness() { + const [attachedFileIds, setAttachedFileIds] = useState([]) + return ( + + setAttachedFileIds((current) => + current.includes(file.id) ? current.filter((id) => id !== file.id) : [...current, file.id], + ) + } + /> + ) + } + + const original = HTMLElement.prototype.scrollIntoView + HTMLElement.prototype.scrollIntoView = function scrollIntoView() { + const scroller = (this as HTMLElement).closest('.overflow-y-auto') + if (scroller instanceof HTMLElement) scroller.scrollTop = 0 + } + + try { + render() + await user.click(screen.getByRole('button', { name: 'داده' })) + const list = document.querySelector('.overflow-y-auto') + expect(list).toBeInstanceOf(HTMLElement) + if (!(list instanceof HTMLElement)) return + list.scrollTop = 140 + await user.click(screen.getByRole('option', { name: /sales/ })) + expect(list.scrollTop).toBe(140) + expect(screen.getByRole('option', { name: /sales/ })).toHaveAttribute('aria-selected', 'true') + } finally { + HTMLElement.prototype.scrollIntoView = original + } + }) + it('attaches an active business connector', async () => { const onSelectConnectors = vi.fn() render( diff --git a/src/features/assistant/input/ResourcePicker.tsx b/src/features/assistant/input/ResourcePicker.tsx index a45ad0e..7279494 100644 --- a/src/features/assistant/input/ResourcePicker.tsx +++ b/src/features/assistant/input/ResourcePicker.tsx @@ -15,7 +15,10 @@ 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 + return ( + ENGINES.find((engine) => engine.id === id || engine.id.toLowerCase() === id.toLowerCase())?.label || + type + ) } function appLabel(type?: string) { diff --git a/src/features/data/ConnectionsPage.test.tsx b/src/features/data/ConnectionsPage.test.tsx new file mode 100644 index 0000000..2606173 --- /dev/null +++ b/src/features/data/ConnectionsPage.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ConnectionsPage } from './ConnectionsPage' + +vi.mock('../auth/AuthContext', () => ({ + useAuth: () => ({ user: { role: 'admin', id: 'usr_1' } }), +})) + +vi.mock('../databases/api', () => ({ + listDatasourceRecords: vi.fn(), + deleteDatasource: vi.fn(), +})) + +import { listDatasourceRecords } from '../databases/api' + +describe('ConnectionsPage', () => { + beforeEach(() => { + vi.mocked(listDatasourceRecords).mockResolvedValue([ + { + id: 'ds_1', + name: 'banktala', + type: 'mongodb', + host: 'localhost', + database: 'test-ban', + config: { host: 'localhost', database: 'test-ban' }, + }, + ]) + }) + + it('puts the engine catalog on top and keeps saved connections full width', async () => { + render( + + + , + ) + + expect(await screen.findByRole('button', { name: 'ClickHouse' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'DuckDB' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'MaxCompute' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Neo4j' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'MongoDB' })).not.toBeInTheDocument() + expect(screen.queryByText('گراف و سند')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'همه' })).not.toBeInTheDocument() + expect(screen.getByText('banktala')).toBeInTheDocument() + expect(screen.queryByRole('complementary')).not.toBeInTheDocument() + }) +}) diff --git a/src/features/data/ConnectionsPage.tsx b/src/features/data/ConnectionsPage.tsx index d1f2479..f8b0c69 100644 --- a/src/features/data/ConnectionsPage.tsx +++ b/src/features/data/ConnectionsPage.tsx @@ -1,22 +1,54 @@ import { useEffect, useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' -import { Database, RefreshCw, Trash2 } from 'lucide-react' +import { Cable, Database, RefreshCw, Search, Trash2 } from 'lucide-react' import { PageHeader } from '../../components/ui/PageHeader' import { ConfirmDialog } from '../../components/ui/ConfirmDialog' import { Badge } from '../../components/ui/StatusBadge' +import { Input } from '../../components/ui/Field' 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 { ENGINES, FILE_ENGINE_IDS, engineById, type EngineDef, 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'] }, +type GroupId = 'sql' | 'warehouse' | 'file' + +const GROUPS: Array<{ id: GroupId; labelKey: string; ids: EngineId[] }> = [ + { + id: 'sql', + labelKey: 'dataHub.groupSql', + ids: ['mysql', 'mariadb', 'oceanbase', 'postgresql', 'oracle', 'mssql'], + }, + { + id: 'warehouse', + labelKey: 'dataHub.groupWarehouse', + ids: ['clickhouse', 'starrocks', 'doris', 'hive', 'gaussdb', 'openGauss', 'vertica', 'maxcompute'], + }, + { id: 'file', labelKey: 'dataHub.groupFile', ids: ['sqlite', 'duckdb', 'spark'] }, ] +const OFFERED_ENGINES = new Set(GROUPS.flatMap((group) => group.ids)) + +function connectionMeta(item: DatasourceRecord) { + const engine = engineById(item.type) + const host = item.host || String(item.config?.host || item.config?.endpoint || '') + const database = item.database || String( + item.config?.database || item.config?.project || item.config?.path || '', + ) + if (FILE_ENGINE_IDS.has(engine.id)) { + return String(item.config?.path || database || t('databases.kindFile')) + } + return [host, database].filter(Boolean).join(' · ') +} + +function engineMatches(engine: EngineDef, query: string) { + if (!query) return true + const hay = `${engine.label} ${engine.id} ${t(engine.blurbKey)}`.toLowerCase() + return hay.includes(query) +} + export function ConnectionsPage() { const { user } = useAuth() const canManage = user?.role !== 'user' @@ -26,6 +58,7 @@ export function ConnectionsPage() { const [deletingConnId, setDeletingConnId] = useState(null) const [connectEngine, setConnectEngine] = useState(null) const [editing, setEditing] = useState(null) + const [query, setQuery] = useState('') const [searchParams, setSearchParams] = useSearchParams() const ordered = useMemo( @@ -33,6 +66,25 @@ export function ConnectionsPage() { [items], ) + const countsByType = useMemo(() => { + const next = new Map() + for (const item of items) { + const id = engineById(item.type).id + next.set(id, (next.get(id) || 0) + 1) + } + return next + }, [items]) + + const visibleGroups = useMemo(() => { + const needle = query.trim().toLowerCase() + return GROUPS.map((group) => ({ + ...group, + engines: group.ids + .map((id) => ENGINES.find((item) => item.id === id) || engineById(id)) + .filter((engine) => engineMatches(engine, needle)), + })).filter((group) => group.engines.length > 0) + }, [query]) + async function load() { setLoading(true) setError(null) @@ -52,9 +104,12 @@ export function ConnectionsPage() { useEffect(() => { const requested = searchParams.get('engine') - if (!canManage || !requested || !ENGINES.some((engine) => engine.id === requested)) return + const matched = requested + ? ENGINES.find((item) => item.id === requested || item.id.toLowerCase() === requested.toLowerCase()) + : undefined + if (!canManage || !matched || !OFFERED_ENGINES.has(matched.id)) return setEditing(null) - setConnectEngine(requested as EngineId) + setConnectEngine(matched.id) const next = new URLSearchParams(searchParams) next.delete('engine') setSearchParams(next, { replace: true }) @@ -72,14 +127,6 @@ export function ConnectionsPage() { } } - 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) @@ -110,97 +157,45 @@ export function ConnectionsPage() {

) : null} -
-
-

{t('dataHub.savedTitle')}

- {loading ? ( - - ) : ordered.length === 0 ? ( -
- - - -

{t('dataHub.savedEmptyTitle')}

-

{t('dataHub.savedEmptyBody')}

-
- ) : ( -
    - {ordered.map((item) => { - const engine = engineById(item.type) - return ( -
  • -
    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} - > - -
    -
    -

    {item.name}

    - {engine.label} -
    -

    - {connectionMeta(item)} -

    -
    - {canManage ? ( - - ) : null} -
    -
  • - ) - })} -
- )} - {ordered.length ? ( -

- - {t('dataHub.openMap')} - -

- ) : null} -
+
+
+
+

{t('dataHub.addEngineTitle')}

+

{t('dataHub.addEngineHint')}

+ {!canManage ? ( +

{t('dataHub.manageOnly')}

+ ) : null} +
+
+ setQuery(event.target.value)} + placeholder={t('dataHub.engineSearch')} + aria-label={t('dataHub.engineSearch')} + leadingIcon={} + /> +
+
- -
+ )} + + +
+

{t('dataHub.savedTitle')}

+ {loading ? ( + + ) : ordered.length === 0 ? ( +
+ + + +

{t('dataHub.savedEmptyTitle')}

+

{t('dataHub.savedEmptyBody')}

+
+ ) : ( +
    + {ordered.map((item) => { + const engine = engineById(item.type) + return ( +
  • +
    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} + > + +
    +
    +

    {item.name}

    + {engine.label} +
    +

    + {connectionMeta(item)} +

    +
    + {canManage ? ( + + ) : null} +
    +
  • + ) + })} +
+ )} + {ordered.length ? ( +

+ + {t('dataHub.openMap')} + +

+ ) : null} +
- - - - + + + +
-
-
-
+
+
+

{t('dataHub.mixLabel')}

-

+

{mapSources ? readyMaps < mapSources ? t('dataHub.mapProgress').replace('{ready}', String(readyMaps)).replace('{total}', String(mapSources)) @@ -116,63 +169,99 @@ export function DataDashboardPage() { : t('dataHub.stepMapHint')} {confirmedEdges ? ` · ${t('dataHub.confirmedLinks').replace('{count}', String(confirmedEdges))}` : ''}

+
+ {mix.map((part) => + part.value ? ( +
+ ) : null, + )} +
+
+ {mix.map((part) => ( + + + {part.label} + {part.value} + {part.value ? ( + + {Math.round((part.value / mixTotal) * 100)}٪ + + ) : null} + + ))} +
- - {t('dataHub.openOrgMap')} - -
-
- {mix.map((part) => - part.value ? ( -
- ) : null, - )} -
-
- {mix.map((part) => ( - - - {part.label} - {part.value} + + {mapSources ? ( + + ) : ( + + + + )} + + {t('dataHub.statDiscovered')} + + {mapSources ? `${readyMaps} / ${mapSources}` : t('dataHub.stepMapHint')} + + + {t('dataHub.openOrgMap')} + + - ))} +
-
+
({ + rows={files.slice(0, 5).map((file) => ({ id: file.id, title: file.title?.trim() || file.name, meta: file.original_name || file.name, + mark: , }))} /> ({ + rows={databases.slice(0, 5).map((item) => ({ id: item.id, title: item.name, meta: engineById(item.type).label, + mark: , }))} /> ({ + rows={apps.slice(0, 5).map((item) => ({ id: item.id, title: item.display_name, meta: item.connector_type, + mark: , }))} />
@@ -186,25 +275,106 @@ function StatCard({ label, value, to, + icon: Icon, + iconWrap, + wash, tone, }: { label: string value: number to: string + icon: LucideIcon + iconWrap: string + wash: string tone: string }) { return ( - -

{value}

-

{label}

+ + + + + + + +

{value}

+

{label}

+ ) } +function CoverageRing({ ready, total }: { ready: number; total: number }) { + const size = 88 + const stroke = 7 + const radius = (size - stroke) / 2 + const circumference = 2 * Math.PI * radius + const ratio = total > 0 ? Math.min(1, ready / total) : 0 + const percent = Math.round(ratio * 100) + return ( +
+ + + + +
+ {percent} + ٪ +
+
+ ) +} + +function FileMark({ file }: { file: KamidsoFile }) { + const raw = (file.kind || kindFromFilename(file.original_name || file.name || '')).toLowerCase() + const kind = raw === 'table' || raw === 'spreadsheet' ? 'excel' : raw + const meta = + kind === 'csv' + ? { icon: Table2, wrap: 'bg-sky-50 text-sky-700' } + : kind === 'json' + ? { icon: Braces, wrap: 'bg-amber-50 text-amber-800' } + : kind === 'parquet' + ? { icon: Layers, wrap: 'bg-slate-100 text-slate-700' } + : { icon: FileSpreadsheet, wrap: 'bg-emerald-50 text-emerald-700' } + const Icon = meta.icon + return ( + + + + ) +} + +function AppMark({ type, name }: { type: string; name: string }) { + if (type === 'didar') return + const letter = name.trim().slice(0, 1) || 'ا' + return ( + + {letter} + + ) +} + function EmptyGuide() { return (
@@ -322,32 +492,49 @@ function RecentList({ to, rows, empty, + total, + icon: Icon, + iconWrap, }: { title: string to: string - rows: Array<{ id: string; title: string; meta: string }> + rows: Array<{ id: string; title: string; meta: string; mark: ReactNode }> empty: boolean + total: number + icon: LucideIcon + iconWrap: string }) { return ( -
-
-

{title}

- +
+
+
+ + + +

{title}

+ {total ? {total} : null} +
+ {empty ? t('dataHub.add') : t('dataHub.view')}
{empty ? ( - + {t('dataHub.noneYet')} ) : ( -
    +
      {rows.map((row) => ( -
    • -

      {row.title}

      -

      - {row.meta} -

      +
    • + + {row.mark} + + {row.title} + + {row.meta} + + +
    • ))}
    diff --git a/src/features/databases/ConnectEngineModal.tsx b/src/features/databases/ConnectEngineModal.tsx index 31d3d6f..e88bdd6 100644 --- a/src/features/databases/ConnectEngineModal.tsx +++ b/src/features/databases/ConnectEngineModal.tsx @@ -30,7 +30,11 @@ function configToValues(engineId: EngineId, config?: Record): R if (config?.database && !next.database) next.database = String(config.database) if (config?.host && !next.host) next.host = String(config.host) if (config?.path && !next.path) next.path = String(config.path) + if (config?.endpoint && !next.endpoint) next.endpoint = String(config.endpoint) + if (config?.project && !next.project) next.project = String(config.project) + if (config?.access_id && !next.access_id) next.access_id = String(config.access_id) next.password = '' + next.access_key = '' return next } @@ -84,7 +88,7 @@ export function ConnectEngineModal({ function payload() { const config: Record = {} for (const field of visibleFields) { - if (field === 'password') continue + if (field === 'password' || field === 'access_key') continue const value = (values[field] || '').trim() if (field === 'port') config.port = value ? Number(value) : engine.defaultPort else if (value) config[field] = value @@ -97,11 +101,28 @@ export function ConnectEngineModal({ name: name.trim() || engine.label, type: engine.id, config, - password: values.password || undefined, + password: values.password || values.access_key || undefined, } } + function missingRequired(): EngineField | undefined { + for (const field of visibleFields) { + if ((field === 'password' || field === 'access_key') && existing) continue + const needed = + engine.required.includes(field) || + (engine.id === 'oracle' && (field === 'service_name' || field === 'sid')) + if (!needed) continue + if (!(values[field] || '').trim()) return field + } + return undefined + } + async function handleTest() { + const missing = missingRequired() + if (missing) { + setError(`${t(FIELD_LABEL[missing])} — ${t('databases.testFailed')}`) + return + } setTesting(true) setError(null) setOkMessage(null) @@ -117,6 +138,11 @@ export function ConnectEngineModal({ } async function handleSave() { + const missing = missingRequired() + if (missing) { + setError(`${t(FIELD_LABEL[missing])} — ${t('databases.saveFailed')}`) + return + } setBusy(true) setError(null) try { @@ -199,14 +225,20 @@ export function ConnectEngineModal({
    {visibleFields.map((field) => { - const isPassword = field === 'password' - const wide = field === 'path' || field === 'host' || field === 'service_name' || field === 'sid' + const isPassword = field === 'password' || field === 'access_key' + const wide = + field === 'path' || + field === 'host' || + field === 'service_name' || + field === 'sid' || + field === 'endpoint' return ( setField(field, event.target.value)} autoComplete={isPassword ? 'new-password' : 'off'} - dir={['path', 'host', 'service_name', 'sid', 'auth_source'].includes(field) ? 'ltr' : undefined} + dir={ + [ + 'path', + 'host', + 'service_name', + 'sid', + 'auth_source', + 'endpoint', + 'project', + 'access_id', + ].includes(field) + ? 'ltr' + : undefined + } placeholder={isPassword && existing ? '••••••••' : undefined} /> diff --git a/src/features/databases/EngineMark.test.tsx b/src/features/databases/EngineMark.test.tsx new file mode 100644 index 0000000..a2adbc9 --- /dev/null +++ b/src/features/databases/EngineMark.test.tsx @@ -0,0 +1,18 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { EngineMark } from './EngineMark' +import { ENGINES } from './engines' + +describe('EngineMark', () => { + it('gives every engine its own mark, including case-insensitive openGauss', () => { + const marks = ENGINES.map((engine) => { + const { container } = render() + return container.firstElementChild?.getAttribute('data-engine') + }) + expect(marks).toEqual(ENGINES.map((engine) => engine.id)) + expect(new Set(marks).size).toBe(ENGINES.length) + + const { container } = render() + expect(container.firstElementChild).toHaveAttribute('data-engine', 'openGauss') + }) +}) diff --git a/src/features/databases/EngineMark.tsx b/src/features/databases/EngineMark.tsx index 97dbb43..e3f071c 100644 --- a/src/features/databases/EngineMark.tsx +++ b/src/features/databases/EngineMark.tsx @@ -1,4 +1,27 @@ -import type { EngineId } from './engines' +import type { ReactNode } from 'react' +import { ENGINES, type EngineId } from './engines' + +const LOGOS: Record ReactNode> = { + mysql: MysqlLogo, + mariadb: MariaLogo, + oceanbase: OceanBaseLogo, + postgresql: PostgresLogo, + oracle: OracleLogo, + mssql: MssqlLogo, + sqlite: SqliteLogo, + duckdb: DuckLogo, + clickhouse: ClickHouseLogo, + starrocks: StarRocksLogo, + doris: DorisLogo, + hive: HiveLogo, + gaussdb: GaussLogo, + openGauss: OpenGaussLogo, + vertica: VerticaLogo, + maxcompute: MaxComputeLogo, + spark: SparkLogo, + neo4j: Neo4jLogo, + mongodb: MongoLogo, +} export function EngineMark({ id, @@ -7,121 +30,262 @@ export function EngineMark({ id: string className?: string }) { - const engine = (id || 'mysql') as EngineId + const resolved = resolveEngineId(id) + const Logo = resolved ? LOGOS[resolved] : GenericLogo return ( - - {engine === 'mysql' ? : null} - {engine === 'postgresql' ? : null} - {engine === 'oracle' ? : null} - {engine === 'mssql' ? : null} - {engine === 'sqlite' ? : null} - {engine === 'neo4j' ? : null} - {engine === 'mongodb' ? : null} - {!['mysql', 'postgresql', 'oracle', 'mssql', 'sqlite', 'neo4j', 'mongodb'].includes(engine) ? ( - - ) : null} + + ) } +function resolveEngineId(id?: string | null): EngineId | null { + const raw = (id || '').trim() + const lower = raw.toLowerCase() + return ENGINES.find((item) => item.id === raw || item.id.toLowerCase() === lower)?.id || null +} + +function Frame({ fill, children }: { fill: string; children: ReactNode }) { + return ( + + + {children} + + ) +} + function MysqlLogo() { return ( - - + + + + + ) +} + +function MariaLogo() { + return ( + + + + + + + + ) +} + +function OceanBaseLogo() { + return ( + + + + + ) } function PostgresLogo() { return ( - - - - - - - - - + + + + + + + + ) } function OracleLogo() { return ( - - + + ) } function MssqlLogo() { return ( - - - - - - - + + + + + + + ) } function SqliteLogo() { return ( - - + + + + ) +} + +function DuckLogo() { + return ( + + + + + + + ) +} + +function ClickHouseLogo() { + return ( + + + + + + ) +} + +function StarRocksLogo() { + return ( + + + + ) +} + +function DorisLogo() { + return ( + + + + + + + ) +} + +function HiveLogo() { + return ( + + + + + + ) +} + +function GaussLogo() { + return ( + + + + + + ) +} + +function OpenGaussLogo() { + return ( + + + + + ) +} + +function VerticaLogo() { + return ( + + + + + ) +} + +function MaxComputeLogo() { + return ( + + + + ) +} + +function SparkLogo() { + return ( + + + + ) } function Neo4jLogo() { return ( - - + + ) } function MongoLogo() { return ( - - + + ) } function GenericLogo() { return ( - - + + ) } diff --git a/src/features/databases/engines.test.ts b/src/features/databases/engines.test.ts new file mode 100644 index 0000000..9cde8a3 --- /dev/null +++ b/src/features/databases/engines.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { defaultValues, ENGINES, engineById, FILE_ENGINE_IDS } from './engines' + +describe('ENGINES catalog', () => { + it('offers every sql_query engine the user can connect', () => { + expect(ENGINES.map((item) => item.id)).toEqual( + expect.arrayContaining([ + 'mysql', + 'mariadb', + 'oceanbase', + 'postgresql', + 'mssql', + 'oracle', + 'sqlite', + 'duckdb', + 'clickhouse', + 'starrocks', + 'doris', + 'hive', + 'gaussdb', + 'openGauss', + 'vertica', + 'maxcompute', + 'spark', + ]), + ) + }) + + it('gives MaxCompute cloud fields instead of host and port', () => { + const engine = engineById('maxcompute') + expect(engine.fields).toEqual(['endpoint', 'project', 'access_id', 'access_key']) + expect(defaultValues(engine).endpoint).toContain('maxcompute') + }) + + it('treats DuckDB and Spark as local files', () => { + expect(FILE_ENGINE_IDS.has('duckdb')).toBe(true) + expect(FILE_ENGINE_IDS.has('spark')).toBe(true) + expect(engineById('duckdb').fields).toEqual(['path']) + expect(engineById('opengauss').id).toBe('openGauss') + }) +}) diff --git a/src/features/databases/engines.ts b/src/features/databases/engines.ts index c070cc4..5156e25 100644 --- a/src/features/databases/engines.ts +++ b/src/features/databases/engines.ts @@ -1,4 +1,23 @@ -export type EngineId = 'mysql' | 'postgresql' | 'oracle' | 'mssql' | 'sqlite' | 'neo4j' | 'mongodb' +export type EngineId = + | 'mysql' + | 'mariadb' + | 'oceanbase' + | 'postgresql' + | 'mssql' + | 'oracle' + | 'sqlite' + | 'duckdb' + | 'clickhouse' + | 'starrocks' + | 'doris' + | 'hive' + | 'gaussdb' + | 'openGauss' + | 'vertica' + | 'maxcompute' + | 'spark' + | 'neo4j' + | 'mongodb' export type EngineField = | 'host' @@ -11,6 +30,10 @@ export type EngineField = | 'service_name' | 'sid' | 'auth_source' + | 'endpoint' + | 'project' + | 'access_id' + | 'access_key' export type EngineDef = { id: EngineId @@ -25,55 +48,39 @@ export type EngineDef = { kindKey: string } +const hostSql = ( + id: EngineId, + label: string, + port: number, + accent: string, + accentSoft: string, + blurbKey: string, + extra: Partial = {}, +): EngineDef => ({ + id, + label, + kind: extra.kind || 'sql', + defaultPort: extra.defaultPort ?? port, + fields: extra.fields || ['host', 'port', 'user', 'password', 'database'], + required: extra.required || ['host', 'user', 'database'], + accent: extra.accent || accent, + accentSoft: extra.accentSoft || accentSoft, + blurbKey: extra.blurbKey || blurbKey, + kindKey: extra.kindKey || 'databases.kindSql', +}) + export const ENGINES: EngineDef[] = [ - { - id: 'mysql', - label: 'MySQL', - kind: 'sql', - defaultPort: 3306, - fields: ['host', 'port', 'user', 'password', 'database'], - required: ['host', 'user', 'database'], - accent: '#00758F', - accentSoft: '#E8F7FA', - blurbKey: 'databases.blurbMysql', - kindKey: 'databases.kindSql', - }, - { - id: 'postgresql', - label: 'PostgreSQL', - kind: 'sql', - defaultPort: 5432, + hostSql('mysql', 'MySQL', 3306, '#00758F', '#E8F7FA', 'databases.blurbMysql'), + hostSql('mariadb', 'MariaDB', 3306, '#C49A6C', '#F8F1E6', 'databases.blurbMariadb'), + hostSql('oceanbase', 'OceanBase', 2881, '#E65A2B', '#FDF0EA', 'databases.blurbOceanbase'), + hostSql('postgresql', 'PostgreSQL', 5432, '#336791', '#EEF4FA', 'databases.blurbPostgres', { fields: ['host', 'port', 'user', 'password', 'database', 'schema'], - required: ['host', 'user', 'database'], - accent: '#336791', - accentSoft: '#EEF4FA', - blurbKey: 'databases.blurbPostgres', - kindKey: 'databases.kindSql', - }, - { - id: 'oracle', - label: 'Oracle', - kind: 'sql', - defaultPort: 1521, + }), + hostSql('oracle', 'Oracle', 1521, '#C74634', '#FDF1EF', 'databases.blurbOracle', { fields: ['host', 'port', 'user', 'password', 'service_name', 'sid'], required: ['host', 'user'], - accent: '#C74634', - accentSoft: '#FDF1EF', - blurbKey: 'databases.blurbOracle', - kindKey: 'databases.kindSql', - }, - { - id: 'mssql', - label: 'SQL Server', - kind: 'sql', - defaultPort: 1433, - fields: ['host', 'port', 'user', 'password', 'database'], - required: ['host', 'user', 'database'], - accent: '#A12127', - accentSoft: '#FBF0F1', - blurbKey: 'databases.blurbMssql', - kindKey: 'databases.kindSql', - }, + }), + hostSql('mssql', 'SQL Server', 1433, '#A12127', '#FBF0F1', 'databases.blurbMssql'), { id: 'sqlite', label: 'SQLite', @@ -86,6 +93,55 @@ export const ENGINES: EngineDef[] = [ blurbKey: 'databases.blurbSqlite', kindKey: 'databases.kindFile', }, + { + id: 'duckdb', + label: 'DuckDB', + kind: 'file', + defaultPort: null, + fields: ['path'], + required: ['path'], + accent: '#FFF000', + accentSoft: '#FFFBEA', + blurbKey: 'databases.blurbDuckdb', + kindKey: 'databases.kindFile', + }, + hostSql('clickhouse', 'ClickHouse', 8123, '#FBBF24', '#FFF8E8', 'databases.blurbClickhouse'), + hostSql('starrocks', 'StarRocks', 9030, '#3759D4', '#EEF1FC', 'databases.blurbStarrocks'), + hostSql('doris', 'Apache Doris', 9030, '#4458C7', '#EEF1FA', 'databases.blurbDoris'), + hostSql('hive', 'Apache Hive', 10000, '#FDE047', '#FFFBEB', 'databases.blurbHive', { + required: ['host'], + }), + hostSql('gaussdb', 'GaussDB', 5432, '#C7000B', '#FDF0F1', 'databases.blurbGaussdb', { + fields: ['host', 'port', 'user', 'password', 'database', 'schema'], + }), + hostSql('openGauss', 'openGauss', 5432, '#E60012', '#FDF0F1', 'databases.blurbOpengauss', { + fields: ['host', 'port', 'user', 'password', 'database', 'schema'], + }), + hostSql('vertica', 'Vertica', 5433, '#1A5A96', '#EAF2F9', 'databases.blurbVertica'), + { + id: 'maxcompute', + label: 'MaxCompute', + kind: 'sql', + defaultPort: null, + fields: ['endpoint', 'project', 'access_id', 'access_key'], + required: ['endpoint', 'project', 'access_id', 'access_key'], + accent: '#FF6A00', + accentSoft: '#FFF3EB', + blurbKey: 'databases.blurbMaxcompute', + kindKey: 'databases.kindSql', + }, + { + id: 'spark', + label: 'Apache Spark', + kind: 'file', + defaultPort: null, + fields: ['path'], + required: ['path'], + accent: '#E25A1C', + accentSoft: '#FDF0EA', + blurbKey: 'databases.blurbSpark', + kindKey: 'databases.kindFile', + }, { id: 'neo4j', label: 'Neo4j', @@ -113,7 +169,13 @@ export const ENGINES: EngineDef[] = [ ] export function engineById(id?: string | null): EngineDef { - return ENGINES.find((item) => item.id === id) || ENGINES[0]! + const raw = (id || '').trim() + const lower = raw.toLowerCase() + return ( + ENGINES.find((item) => item.id === raw) || + ENGINES.find((item) => item.id.toLowerCase() === lower) || + ENGINES[0]! + ) } export function defaultValues(engine: EngineDef): Record { @@ -123,7 +185,9 @@ export function defaultValues(engine: EngineDef): Record { else if (field === 'schema') next.schema = 'public' else if (field === 'path' && engine.id === 'sqlite') next.path = '/app/pilot/examples/Walmart_Sales.db' else if (field === 'database' && engine.id === 'neo4j') next.database = 'neo4j' + else if (field === 'database' && engine.id === 'hive') next.database = 'default' else if (field === 'auth_source') next.auth_source = 'admin' + else if (field === 'endpoint') next.endpoint = 'https://service.cn-hangzhou.maxcompute.aliyun.com/api' else next[field] = '' } return next @@ -140,12 +204,18 @@ export const FIELD_LABEL: Record = { service_name: 'databases.fieldServiceName', sid: 'databases.fieldSid', auth_source: 'databases.fieldAuthSource', + endpoint: 'databases.fieldEndpoint', + project: 'databases.fieldProject', + access_id: 'databases.fieldAccessId', + access_key: 'databases.fieldAccessKey', } export const FIELD_HINT: Record = { 'mysql.host': 'databases.hintMysqlHost', 'mysql.port': 'databases.hintMysqlPort', 'mysql.database': 'databases.hintMysqlDatabase', + 'mariadb.port': 'databases.hintMysqlPort', + 'oceanbase.port': 'databases.hintOceanbasePort', 'postgresql.host': 'databases.hintPgHost', 'postgresql.port': 'databases.hintPgPort', 'postgresql.schema': 'databases.hintPgSchema', @@ -154,8 +224,22 @@ export const FIELD_HINT: Record = { 'oracle.sid': 'databases.hintOracleSid', 'mssql.port': 'databases.hintMssqlPort', 'sqlite.path': 'databases.hintSqlitePath', + 'duckdb.path': 'databases.hintDuckdbPath', + 'clickhouse.port': 'databases.hintClickhousePort', + 'starrocks.port': 'databases.hintStarrocksPort', + 'doris.port': 'databases.hintDorisPort', + 'hive.port': 'databases.hintHivePort', + 'hive.database': 'databases.hintHiveDatabase', + 'gaussdb.schema': 'databases.hintPgSchema', + 'openGauss.schema': 'databases.hintPgSchema', + 'vertica.port': 'databases.hintVerticaPort', + 'maxcompute.endpoint': 'databases.hintMaxcomputeEndpoint', + 'maxcompute.project': 'databases.hintMaxcomputeProject', + 'spark.path': 'databases.hintSparkPath', 'neo4j.port': 'databases.hintNeo4jPort', 'neo4j.database': 'databases.hintNeo4jDatabase', 'mongodb.port': 'databases.hintMongoPort', 'mongodb.auth_source': 'databases.hintMongoAuth', } + +export const FILE_ENGINE_IDS = new Set(['sqlite', 'duckdb', 'spark']) diff --git a/src/features/knowledge/ChunkBrowser.tsx b/src/features/knowledge/ChunkBrowser.tsx index eb9a51f..e19445d 100644 --- a/src/features/knowledge/ChunkBrowser.tsx +++ b/src/features/knowledge/ChunkBrowser.tsx @@ -1,9 +1,13 @@ import { useEffect, useState } from 'react' +import { Search } from 'lucide-react' +import { Button } from '../../components/Button' import { Input } from '../../components/ui/Field' import { t } from '../../i18n' import { listKnowledgeChunks } from './api' import type { KnowledgeChunk } from '../../contracts/knowledge' +const PAGE_SIZE = 12 + export function ChunkBrowser({ spaceId, documentId, @@ -20,7 +24,7 @@ export function ChunkBrowser({ useEffect(() => { let cancelled = false setLoading(true) - void listKnowledgeChunks(spaceId, documentId, { page, pageSize: 12, q: query || undefined }) + void listKnowledgeChunks(spaceId, documentId, { page, pageSize: PAGE_SIZE, q: query || undefined }) .then((payload) => { if (cancelled) return setItems(payload.items) @@ -40,45 +44,57 @@ export function ChunkBrowser({ } }, [spaceId, documentId, page, query]) + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + return (
    - { - setPage(1) - setQuery(event.target.value) - }} - placeholder={t('knowledge.searchChunks')} - /> +

    {t('knowledge.chunksLead')}

    +
    + { + setPage(1) + setQuery(event.target.value) + }} + placeholder={t('knowledge.searchChunks')} + leadingIcon={} + /> +
    {loading ? (

    {t('knowledge.loading')}

    ) : items.length === 0 ? ( -

    {t('knowledge.chunksEmpty')}

    +
    + {t('knowledge.chunksEmpty')} +
    ) : (
    - {items.map((chunk) => ( -
    + {items.map((chunk, index) => ( +
    +

    + {t('knowledge.chunks')} {(page - 1) * PAGE_SIZE + index + 1} +

    {chunk.content}

    ))}
    )} - {total > 12 ? ( -
    - - {t('knowledge.page')} {page} + {total > PAGE_SIZE ? ( +
    + + {t('knowledge.page')} {page} / {pages}
    - - + +
    ) : null} diff --git a/src/features/knowledge/CreateSpaceModal.tsx b/src/features/knowledge/CreateSpaceModal.tsx index 351b57b..d5580e7 100644 --- a/src/features/knowledge/CreateSpaceModal.tsx +++ b/src/features/knowledge/CreateSpaceModal.tsx @@ -44,6 +44,7 @@ export function CreateSpaceModal({ title, submitLabel, variant = 'space', + initialKind, onClose, onSubmit, }: { @@ -51,6 +52,7 @@ export function CreateSpaceModal({ title: string submitLabel: string variant?: 'space' | 'document' + initialKind?: SourceKind onClose: () => void onSubmit: (payload: { name: string @@ -82,7 +84,7 @@ export function CreateSpaceModal({ if (!open) return setName('') setDescription('') - setKind('file') + setKind(initialKind || 'file') setUrl('') setText('') setTextTitle('') @@ -93,7 +95,7 @@ export function CreateSpaceModal({ setError(null) setBusy(false) void listWorkspaceFiles().then(setLibrary).catch(() => setLibrary([])) - }, [open]) + }, [open, initialKind]) const knowledgeLibrary = useMemo( () => library.filter((file) => isKnowledgeFileKind(file.kind)), diff --git a/src/features/knowledge/DocumentTable.tsx b/src/features/knowledge/DocumentTable.tsx index b280fdf..93b6d73 100644 --- a/src/features/knowledge/DocumentTable.tsx +++ b/src/features/knowledge/DocumentTable.tsx @@ -1,4 +1,5 @@ -import { RefreshCw, Trash2 } from 'lucide-react' +import { FileText, Globe, RefreshCw, Trash2, Type, type LucideIcon } from 'lucide-react' +import { Button } from '../../components/Button' import { StatusBadge } from '../../components/ui/StatusBadge' import { t } from '../../i18n' import type { KnowledgeDocument } from '../../contracts/knowledge' @@ -9,65 +10,84 @@ export function DocumentTable({ onSync, onDelete, onOpenChunks, + onAdd, }: { documents: KnowledgeDocument[] onSync: (id: string) => void onDelete: (id: string) => void onOpenChunks: (id: string) => void + onAdd?: () => void }) { if (!documents.length) { - return

    {t('knowledge.documentsEmpty')}

    + return ( +
    + + + +

    {t('knowledge.documentsEmptyTitle')}

    +

    {t('knowledge.documentsEmptyBody')}

    + {onAdd ? ( +
    + +
    + ) : null} +
    + ) } + return ( -
    - - - - - - - - - - - {documents.map((doc) => ( - - - - - - - - ))} - -
    {t('knowledge.document')}{t('knowledge.type')}{t('knowledge.chunks')}{t('knowledge.status')} -
    - + +
    + - {doc.result ?

    {doc.result}

    : null} -
    {doc.doc_type}{doc.chunk_size} - - -
    - - -
    -
    -
    + +
    +
    + + ) + })} +
) } + +function docMark(type: string): { icon: LucideIcon; wrap: string } { + const key = type.toLowerCase() + if (key.includes('url') || key.includes('web') || key.includes('html')) { + return { icon: Globe, wrap: 'bg-sky-50 text-sky-700' } + } + if (key.includes('text') || key === 'txt' || key === 'md' || key.includes('markdown')) { + return { icon: Type, wrap: 'bg-amber-50 text-amber-800' } + } + if (key.includes('pdf')) return { icon: FileText, wrap: 'bg-rose-50 text-rose-700' } + return { icon: FileText, wrap: 'bg-violet-50 text-violet-700' } +} diff --git a/src/features/knowledge/KnowledgeDetail.tsx b/src/features/knowledge/KnowledgeDetail.tsx index 81208f7..8474732 100644 --- a/src/features/knowledge/KnowledgeDetail.tsx +++ b/src/features/knowledge/KnowledgeDetail.tsx @@ -1,10 +1,11 @@ import { useEffect, useMemo, useState } from 'react' -import { ArrowRight, BookOpen, Plus } from 'lucide-react' +import { ArrowRight, BookOpen, Check, FileText, Layers, Plus, Search, type LucideIcon } from 'lucide-react' import { Link, useParams } from 'react-router-dom' import { Button } from '../../components/Button' import { PageShell } from '../../components/ui/PageShell' import { PageHeader } from '../../components/ui/PageHeader' import { SegmentedTabs } from '../../components/ui/SegmentedTabs' +import { TableSkeleton } from '../../components/ui/Skeleton' import { StatusBadge } from '../../components/ui/StatusBadge' import { ConfirmDialog } from '../../components/ui/ConfirmDialog' import { t } from '../../i18n' @@ -64,9 +65,14 @@ export function KnowledgeDetail() { return () => window.clearInterval(timer) }, [live, id]) + const readyDocs = documents.filter((doc) => String(doc.status).toUpperCase() === 'FINISHED').length + return ( - + {t('knowledge.back')} @@ -74,16 +80,7 @@ export function KnowledgeDetail() { icon={BookOpen} title={space?.name || t('knowledge.title')} subtitle={space?.description || t('knowledge.detailSubtitle')} - meta={ - space ? ( - <> - - - {space.document_count} {t('knowledge.documents')} ? {space.chunk_count} {t('knowledge.chunks')} - - - ) : null - } + meta={space ? : null} action={ } /> - {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null} {loading ? ( -

{t('knowledge.loading')}

+ ) : ( <> +
+ setTab('documents')} + /> + setTab('chunks')} + /> + +
setAdding(true)} onSync={(docId) => { void syncKnowledgeDocument(id, docId) .then(() => load()) @@ -123,10 +149,39 @@ export function KnowledgeDetail() { /> ) : null} {tab === 'chunks' ? ( - selectedDoc ? ( - + documents.length === 0 ? ( +

+ {t('knowledge.chunksEmpty')} +

) : ( -

{t('knowledge.chunksEmpty')}

+ <> + {documents.length > 1 ? ( +
+ {documents.map((doc) => { + const active = doc.id === selectedDoc + return ( + + ) + })} +
+ ) : null} + {selectedDoc ? ( + + ) : ( +

{t('knowledge.chunksEmpty')}

+ )} + ) ) : null} {tab === 'retrieve' ? : null} @@ -162,3 +217,38 @@ export function KnowledgeDetail() { ) } + +function DetailMetric({ + label, + value, + icon: Icon, + wrap, + active = false, + onClick, +}: { + label: string + value: number + icon: LucideIcon + wrap: string + active?: boolean + onClick?: () => void +}) { + const className = `rounded-2xl border bg-panel p-4 text-right shadow-card transition ${ + active ? 'border-accent/40' : 'border-line' + } ${onClick ? 'hover:-translate-y-0.5 hover:border-line-strong' : ''}` + const body = ( + <> + + + +

{value}

+

{label}

+ + ) + if (!onClick) return
{body}
+ return ( + + ) +} diff --git a/src/features/knowledge/KnowledgeList.tsx b/src/features/knowledge/KnowledgeList.tsx index 12b24a2..e52c015 100644 --- a/src/features/knowledge/KnowledgeList.tsx +++ b/src/features/knowledge/KnowledgeList.tsx @@ -1,10 +1,22 @@ -import { useEffect, useState } from 'react' -import { BookOpen, Plus, Search, Trash2 } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { + BookOpen, + FileText, + FolderOpen, + Globe, + Layers, + MessageSquare, + Plus, + Search, + Trash2, + Type, + Upload, + type LucideIcon, +} from 'lucide-react' import { Link } from 'react-router-dom' import { Button } from '../../components/Button' import { PageShell } from '../../components/ui/PageShell' import { PageHeader } from '../../components/ui/PageHeader' -import { EmptyState } from '../../components/ui/EmptyState' import { CardGridSkeleton } from '../../components/ui/Skeleton' import { StatusBadge } from '../../components/ui/StatusBadge' import { ConfirmDialog } from '../../components/ui/ConfirmDialog' @@ -13,14 +25,29 @@ import { t } from '../../i18n' import { createKnowledgeSpace, deleteKnowledgeSpace, listKnowledgeSpaces } from './api' import { CreateSpaceModal } from './CreateSpaceModal' import { spaceStatusLabel } from './status' +import type { SourceKind } from './types' import type { KnowledgeSpace } from '../../contracts/knowledge' +const STEPS = [ + { icon: BookOpen, title: 'knowledge.stepSpace', hint: 'knowledge.stepSpaceHint', wrap: 'bg-accent-soft text-accent' }, + { icon: FileText, title: 'knowledge.stepSource', hint: 'knowledge.stepSourceHint', wrap: 'bg-emerald-50 text-emerald-700' }, + { icon: MessageSquare, title: 'knowledge.stepChat', hint: 'knowledge.stepChatHint', wrap: 'bg-violet-50 text-violet-700' }, +] as const + +const SOURCE_CARDS: Array<{ id: SourceKind; icon: LucideIcon; label: string; hint: string; wrap: string }> = [ + { id: 'file', icon: Upload, label: 'knowledge.sourceFile', hint: 'knowledge.sourceFileHint', wrap: 'bg-emerald-50 text-emerald-700' }, + { id: 'library', icon: FolderOpen, label: 'knowledge.sourceLibrary', hint: 'knowledge.sourceLibraryHint', wrap: 'bg-sky-50 text-sky-800' }, + { id: 'url', icon: Globe, label: 'knowledge.sourceUrl', hint: 'knowledge.sourceUrlHint', wrap: 'bg-amber-50 text-amber-800' }, + { id: 'text', icon: Type, label: 'knowledge.sourceText', hint: 'knowledge.sourceTextHint', wrap: 'bg-violet-50 text-violet-700' }, +] + export function KnowledgeList() { const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [query, setQuery] = useState('') const [creating, setCreating] = useState(false) + const [createKind, setCreateKind] = useState(undefined) const [deleting, setDeleting] = useState(null) async function load() { @@ -45,6 +72,20 @@ export function KnowledgeList() { return `${item.name} ${item.description || ''}`.toLowerCase().includes(needle) }) + const totals = useMemo( + () => ({ + documents: items.reduce((sum, item) => sum + item.document_count, 0), + chunks: items.reduce((sum, item) => sum + item.chunk_count, 0), + ready: items.filter((item) => item.status === 'ready').length, + }), + [items], + ) + + function openCreate(kind?: SourceKind) { + setCreateKind(kind) + setCreating(true) + } + return ( setCreating(true)}> + } /> -
- setQuery(event.target.value)} - placeholder={t('knowledge.search')} - leadingIcon={} - /> -
- {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null} {loading ? ( - ) : filtered.length === 0 ? ( - - - + ) : items.length === 0 ? ( + ) : ( -
- {filtered.map((item) => ( - -
- - - -
-
-

{item.name}

- -
- {item.description ? ( -

{item.description}

- ) : null} -

- {item.document_count} {t('knowledge.documents')} ? {item.chunk_count}{' '} - {t('knowledge.chunks')} -

-
- -
- - ))} -
+ +
+ + + +
+
+

{item.name}

+ +
+ {item.description ? ( +

{item.description}

+ ) : ( +

{t('knowledge.detailSubtitle')}

+ )} +

+ + {item.document_count} {t('knowledge.documents')} + + + {item.chunk_count} {t('knowledge.chunks')} + +

+
+ +
+ + ))} +
+ )} + )} setCreating(false)} + onClose={() => { + setCreating(false) + setCreateKind(undefined) + }} onSubmit={async (payload) => { await createKnowledgeSpace(payload) await load() @@ -148,3 +212,86 @@ export function KnowledgeList() {
) } + +function Metric({ + label, + value, + icon: Icon, + wrap, +}: { + label: string + value: number + icon: LucideIcon + wrap: string +}) { + return ( +
+ + + +

{value}

+

{label}

+
+ ) +} + +function EmptyGuide({ onCreate }: { onCreate: (kind?: SourceKind) => void }) { + return ( +
+
+
+

{t('knowledge.emptyKicker')}

+

{t('knowledge.emptyTitle')}

+

{t('knowledge.emptyDescription')}

+
+ +
+
    + {STEPS.map((step, index) => { + const Icon = step.icon + return ( +
  1. + + + + + {index + 1} + {t(step.title)} + {t(step.hint)} + +
  2. + ) + })} +
+
+
+
+

{t('knowledge.suggestSources')}

+
+ {SOURCE_CARDS.map((item) => { + const Icon = item.icon + return ( + + ) + })} +
+
+
+ ) +} diff --git a/src/features/knowledge/RecallTestPanel.tsx b/src/features/knowledge/RecallTestPanel.tsx index f581526..0cb0331 100644 --- a/src/features/knowledge/RecallTestPanel.tsx +++ b/src/features/knowledge/RecallTestPanel.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { Search } from 'lucide-react' import { Button } from '../../components/Button' import { LabeledInput } from '../../components/ui/Field' import { t } from '../../i18n' @@ -10,6 +11,7 @@ export function RecallTestPanel({ spaceId }: { spaceId: string }) { const [topK, setTopK] = useState('5') const [hits, setHits] = useState([]) const [busy, setBusy] = useState(false) + const [ran, setRan] = useState(false) const [error, setError] = useState(null) async function run() { @@ -22,9 +24,11 @@ export function RecallTestPanel({ spaceId }: { spaceId: string }) { top_k: Number(topK) || 5, }) setHits(next) + setRan(true) } catch (err) { setError(err instanceof Error ? err.message : t('knowledge.retrieveFailed')) setHits([]) + setRan(true) } finally { setBusy(false) } @@ -32,36 +36,58 @@ export function RecallTestPanel({ spaceId }: { spaceId: string }) { return (
-
- setQuery(event.target.value)} - /> - setTopK(event.target.value)} - /> -
-
-
- {error ?

{error}

: null} + + {error ? ( +

+ {error} +

+ ) : null}
- {hits.map((hit, index) => ( -
-
- {hit.doc_name || t('knowledge.unknownDoc')} - {hit.score.toFixed(3)} -
-

{hit.content}

-
- ))} - {!busy && hits.length === 0 ? ( -

{t('knowledge.retrieveEmpty')}

+ {hits.map((hit, index) => { + const percent = hit.score >= 0 && hit.score <= 1 ? Math.round(hit.score * 100) : null + return ( +
+
+ {hit.doc_name || t('knowledge.unknownDoc')} + {hit.score.toFixed(3)} +
+ {percent !== null ? ( + + + + ) : null} +

{hit.content}

+
+ ) + })} + {!busy && ran && hits.length === 0 ? ( +
+ {t('knowledge.retrieveEmpty')} +
) : null}
diff --git a/src/i18n/en.ts b/src/i18n/en.ts index d6410f6..b237ab6 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -142,6 +142,23 @@ export const en = { search: 'Search spaces', emptyTitle: 'No knowledge spaces yet', emptyDescription: 'Create a space and add a file, URL, or text.', + emptyKicker: 'Get started', + stepSpace: 'Space', + stepSpaceHint: 'Name one topic for the organization', + stepSource: 'Document', + stepSourceHint: 'A file, web page, or text', + stepChat: 'Chat', + stepChatHint: 'Pick this space next to a question', + suggestSources: 'Choose a source', + statSpaces: 'Spaces', + statDocuments: 'Documents', + statChunks: 'Chunks', + statReady: 'Ready', + noMatches: 'No space matches this search.', + documentsEmptyTitle: 'This space has no documents yet', + documentsEmptyBody: 'Add a file, URL, or text so chunks can be built and searched in chat.', + chunksLead: 'The document is split into pieces that chat uses to answer.', + retrieveLead: 'Ask a question to see which pieces come back.', loading: 'Loading…', loadFailed: 'Could not load knowledge spaces.', saveFailed: 'Could not save.', @@ -222,10 +239,10 @@ export const en = { }, databases: { pageTitle: 'Databases', - pageSubtitle: 'Connect MySQL, PostgreSQL, Oracle, SQL Server, SQLite, Neo4j, and MongoDB, plus Excel and CSV tables.', + pageSubtitle: 'Connect relational, file, graph, and document engines. Each one has its own test-and-save form.', connectTitle: 'Configure connection', editTitle: 'Edit connection', - connectHint: 'Connect MySQL, PostgreSQL, Oracle, SQL Server, SQLite, Neo4j, or MongoDB to Kamidso.', + connectHint: 'Pick an engine, test the connection, then save it.', connect: 'New connection', engine: 'Database engine', name: 'Display name', @@ -236,10 +253,14 @@ export const en = { fieldPassword: 'Password', fieldDatabase: 'Database', fieldSchema: 'Schema', - fieldPath: 'SQLite file path', + fieldPath: 'File path', fieldServiceName: 'Service name', fieldSid: 'SID', fieldAuthSource: 'Auth source', + fieldEndpoint: 'Endpoint', + fieldProject: 'Project', + fieldAccessId: 'AccessKey ID', + fieldAccessKey: 'AccessKey Secret', passwordKeep: 'Leave blank to keep the stored password.', test: 'Test connection', testOk: 'Connection succeeded.', @@ -277,10 +298,22 @@ export const en = { kindGraph: 'Graph', kindDocument: 'Document', blurbMysql: 'The most common engine for web and operational systems.', + blurbMariadb: 'MySQL-compatible, for open-source operational systems.', + blurbOceanbase: 'Distributed SQL with the MySQL protocol.', blurbPostgres: 'Analytics, reporting, and structured schemas.', blurbOracle: 'Enterprise systems; Service Name or SID.', blurbMssql: 'Microsoft environments and SQL Server warehouses.', blurbSqlite: 'A local file, with no separate server.', + blurbDuckdb: 'Columnar analytics on a local file.', + blurbClickhouse: 'Fast columnar analytics for large reports.', + blurbStarrocks: 'Analytical warehouse with the MySQL protocol.', + blurbDoris: 'Real-time warehouse, MySQL-compatible.', + blurbHive: 'Hadoop warehouse; default port 10000.', + blurbGaussdb: 'Enterprise database with PostgreSQL compatibility.', + blurbOpengauss: 'Open-source database with PostgreSQL compatibility.', + blurbVertica: 'Columnar warehouse for large-scale analytics.', + blurbMaxcompute: 'Alibaba Cloud managed warehouse (ODPS).', + blurbSpark: 'A data file for Spark SQL (parquet, csv, json, …).', blurbNeo4j: 'Relationships and networks with Cypher.', blurbMongodb: 'JSON documents for query and aggregation.', hintMysqlHost: 'Example: 127.0.0.1 or db.internal', @@ -294,6 +327,17 @@ export const en = { hintOracleSid: 'Instance SID if you do not use a service name', hintMssqlPort: 'Default 1433', hintSqlitePath: 'Example: /app/pilot/examples/Walmart_Sales.db — the Docker example path is resolved locally.', + hintDuckdbPath: 'Path to a .duckdb file on the server.', + hintOceanbasePort: 'Default 2881', + hintClickhousePort: 'HTTP is usually 8123', + hintStarrocksPort: 'MySQL protocol port is usually 9030', + hintDorisPort: 'MySQL protocol port is usually 9030', + hintHivePort: 'HiveServer2 is usually 10000', + hintHiveDatabase: 'Defaults to default if empty', + hintVerticaPort: 'Default 5433', + hintMaxcomputeEndpoint: 'Regional API URL, e.g. cn-hangzhou', + hintMaxcomputeProject: 'MaxCompute project name', + hintSparkPath: 'Server path to parquet / csv / json / orc.', hintNeo4jPort: 'Bolt is usually 7687', hintNeo4jDatabase: 'Default neo4j', hintMongoPort: 'Default 27017', @@ -361,10 +405,15 @@ export const en = { 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.', + addEngineTitle: 'Add a database', + addEngineHint: 'Pick an engine from the catalog. Its settings, test, and save form opens separately.', + engineSearch: 'Search engines', + engineNoMatches: 'No engine matches this filter.', + groupAll: 'All', groupSql: 'Relational', - groupOther: 'File, graph, and document', + groupWarehouse: 'Warehouse and analytics', + groupFile: 'Local file', + groupOther: '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', diff --git a/src/i18n/fa.ts b/src/i18n/fa.ts index 12dd588..120b3a0 100644 --- a/src/i18n/fa.ts +++ b/src/i18n/fa.ts @@ -169,6 +169,23 @@ export const fa = { search: 'جست‌وجوی فضا', emptyTitle: 'هنوز فضای دانشی نیست', emptyDescription: 'یک فضا بسازید و فایل، نشانی وب یا متن اضافه کنید.', + emptyKicker: 'شروع کار', + stepSpace: 'فضا', + stepSpaceHint: 'یک موضوع سازمان را نام‌گذاری کنید', + stepSource: 'سند', + stepSourceHint: 'فایل، صفحه وب یا متن', + stepChat: 'گفتگو', + stepChatHint: 'همین فضا را کنار پرسش انتخاب کنید', + suggestSources: 'منبع را انتخاب کنید', + statSpaces: 'فضا', + statDocuments: 'سند', + statChunks: 'قطعه', + statReady: 'آماده', + noMatches: 'فضایی با این مشخصات پیدا نشد.', + documentsEmptyTitle: 'این فضا هنوز سندی ندارد', + documentsEmptyBody: 'یک فایل، نشانی وب یا متن اضافه کنید تا قطعه‌ها ساخته شوند و در گفتگو قابل جست‌وجو باشند.', + chunksLead: 'متن سند به قطعه‌هایی بریده شده که گفتگو از آن‌ها جواب می‌گیرد.', + retrieveLead: 'پرسشی بنویسید تا ببینید کدام قطعه‌ها به آن نزدیک‌اند.', loading: 'در حال بارگذاری…', loadFailed: 'بارگذاری فضاهای دانش ناموفق بود.', saveFailed: 'ذخیره ناموفق بود.', @@ -249,10 +266,10 @@ export const fa = { }, databases: { pageTitle: 'پایگاه داده', - pageSubtitle: 'اتصال MySQL، PostgreSQL، Oracle، SQL Server، SQLite، Neo4j و MongoDB، به‌همراه فایل‌های جدولی Excel و CSV.', + pageSubtitle: 'اتصال پایگاه‌های رابطه‌ای، فایل‌محور، گراف و سندگرا؛ هر موتور فرم تست و ذخیرهٔ خودش را دارد.', connectTitle: 'تنظیم اتصال', editTitle: 'ویرایش اتصال', - connectHint: 'MySQL، PostgreSQL، Oracle، SQL Server، SQLite، Neo4j و MongoDB را به کامیدسو وصل کنید.', + connectHint: 'موتور را انتخاب کنید، اتصال را بیازمایید و ذخیره کنید.', connect: 'اتصال جدید', engine: 'نوع پایگاه داده', name: 'نام نمایشی', @@ -263,10 +280,14 @@ export const fa = { fieldPassword: 'رمز عبور', fieldDatabase: 'نام پایگاه', fieldSchema: 'اسکیما', - fieldPath: 'مسیر فایل SQLite', + fieldPath: 'مسیر فایل', fieldServiceName: 'Service name', fieldSid: 'SID', fieldAuthSource: 'Auth source', + fieldEndpoint: 'Endpoint', + fieldProject: 'Project', + fieldAccessId: 'AccessKey ID', + fieldAccessKey: 'AccessKey Secret', passwordKeep: 'خالی بگذارید تا رمز ذخیره‌شده حفظ شود.', test: 'آزمون اتصال', testOk: 'اتصال برقرار شد.', @@ -304,10 +325,22 @@ export const fa = { kindGraph: 'گراف', kindDocument: 'سندگرا', blurbMysql: 'رایج‌ترین پایگاه وب و سامانه‌های عملیاتی.', + blurbMariadb: 'سازگار با MySQL؛ سامانه‌های متن‌باز و عملیاتی.', + blurbOceanbase: 'دیتابیس توزیع‌شده سازگار با پروتکل MySQL.', blurbPostgres: 'تحلیل، گزارش و داده‌های ساخت‌یافته با اسکیما.', blurbOracle: 'سامانه‌های سازمانی؛ Service Name یا SID.', blurbMssql: 'محیط‌های مایکروسافت و انبارهای SQL Server.', blurbSqlite: 'یک فایل محلی، بدون سرور جداگانه.', + blurbDuckdb: 'تحلیل ستونی روی یک فایل محلی.', + blurbClickhouse: 'تحلیل سریع ستونی برای گزارش‌های حجیم.', + blurbStarrocks: 'انبار تحلیلی با پروتکل MySQL.', + blurbDoris: 'انبار بلادرنگ سازگار با MySQL.', + blurbHive: 'انبار داده روی Hadoop؛ پورت پیش‌فرض 10000.', + blurbGaussdb: 'دیتابیس سازمانی سازگار با PostgreSQL.', + blurbOpengauss: 'دیتابیس متن‌باز سازگار با PostgreSQL.', + blurbVertica: 'انبار ستونی برای تحلیل در مقیاس بزرگ.', + blurbMaxcompute: 'انبار مدیریت‌شده علی‌بابا کلاد (ODPS).', + blurbSpark: 'فایل داده برای Spark SQL (parquet، csv، json، …).', blurbNeo4j: 'روابط و شبکه با زبان Cypher.', blurbMongodb: 'سندهای JSON برای جست‌وجو و تجمیع.', hintMysqlHost: 'مثال: 127.0.0.1 یا db.internal', @@ -321,6 +354,17 @@ export const fa = { hintOracleSid: 'SID نمونه، اگر Service name ندارید', hintMssqlPort: 'پیش‌فرض 1433', hintSqlitePath: 'مثال: /app/pilot/examples/Walmart_Sales.db — مسیر Docker هم روی ویندوز به نمونه والمارت وصل می‌شود.', + hintDuckdbPath: 'مسیر فایل .duckdb روی سرور.', + hintOceanbasePort: 'پیش‌فرض 2881', + hintClickhousePort: 'HTTP معمولاً 8123', + hintStarrocksPort: 'پورت MySQL معمولاً 9030', + hintDorisPort: 'پورت MySQL معمولاً 9030', + hintHivePort: 'HiveServer2 معمولاً 10000', + hintHiveDatabase: 'اگر خالی باشد default استفاده می‌شود', + hintVerticaPort: 'پیش‌فرض 5433', + hintMaxcomputeEndpoint: 'آدرس API منطقه، مثلاً cn-hangzhou', + hintMaxcomputeProject: 'نام پروژهٔ MaxCompute', + hintSparkPath: 'مسیر parquet / csv / json / orc روی سرور.', hintNeo4jPort: 'Bolt معمولاً 7687', hintNeo4jDatabase: 'پیش‌فرض neo4j', hintMongoPort: 'پیش‌فرض 27017', @@ -388,10 +432,15 @@ export const fa = { savedTitle: 'اتصال‌های ذخیره‌شده', savedEmptyTitle: 'هنوز اتصالی ذخیره نشده', savedEmptyBody: 'یک موتور را انتخاب کنید و مشخصات دسترسی را وارد کنید.', - addEngineTitle: 'موتور جدید', - addEngineHint: 'هر موتور فرم تنظیمات خودش را دارد.', + addEngineTitle: 'افزودن پایگاه', + addEngineHint: 'موتور را از ردیف بالا انتخاب کنید؛ فرم تنظیمات، آزمون و ذخیره جدا باز می‌شود.', + engineSearch: 'جستجوی موتور', + engineNoMatches: 'موتوری با این مشخصات پیدا نشد.', + groupAll: 'همه', groupSql: 'رابطه‌ای', - groupOther: 'فایل، گراف و سند', + groupWarehouse: 'انبار و تحلیلی', + groupFile: 'فایل محلی', + groupOther: 'گراف و سند', manageOnly: 'ساخت و ویرایش اتصال برای مدیر فضای کاری است.', connectionCount: '{count} اتصال', openMap: 'کشف ساختار و ارتباط‌ها در نقشه سازمان',