Expand the database catalog and make knowledge spaces easier to start.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
javadandCursor committed 2026-10-13 14:23:07 +03:30
1 parent b17782eec2
commit e9171be229
20 files changed
+1619 -483

No files matched your search

+14 -10
View File
@@ -1,33 +1,37 @@
import type { LucideIcon } from 'lucide-react'
export function SegmentedTabs<T extends string>({
items,
value,
onChange,
className = '',
}: {
items: ReadonlyArray<readonly [T, string]>
items: ReadonlyArray<{ id: T; label: string; icon?: LucideIcon }>
value: T
onChange: (value: T) => void
className?: string
}) {
return (
<div
className={`inline-flex gap-1 rounded-xl border border-line bg-surface-subtle p-1 ${className}`}
className={`inline-flex max-w-full gap-1 overflow-x-auto rounded-2xl border border-line bg-panel p-1 shadow-card ${className}`}
>
{items.map(([id, label]) => {
const active = value === id
{items.map((item) => {
const Icon = item.icon
const active = value === item.id
return (
<button
key={id}
key={item.id}
type="button"
aria-pressed={active}
onClick={() => onChange(id)}
className={`rounded-lg px-3.5 py-1.5 text-sm transition ${
onClick={() => onChange(item.id)}
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm whitespace-nowrap transition ${
active
? 'bg-panel font-medium text-ink shadow-sm'
: 'text-ink-muted hover:text-ink'
? 'bg-accent-soft font-medium text-accent'
: 'text-ink-muted hover:bg-surface-subtle hover:text-ink'
}`}
>
{label}
{Icon ? <Icon className="h-4 w-4" /> : null}
{item.label}
</button>
)
})}
+49 -16
View File
@@ -78,6 +78,8 @@ export function SelectMenu({
const [highlight, setHighlight] = useState(-1)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const menuRef = useRef<HTMLDivElement | null>(null)
const listRef = useRef<HTMLDivElement | null>(null)
const scrollRequest = useRef<number | null>(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<HTMLElement>(`[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<HTMLElement>(`[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<HTMLButtonElement>) {
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}`}
>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5">
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5">
{options.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-ink-muted">{emptyLabel}</p>
) : (
@@ -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)
@@ -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<string[]>([])
return (
<ResourcePicker
databases={[]}
knowledge={[]}
selectedDbs={[]}
selectedKnowledge={null}
onSelectDbs={vi.fn()}
onSelectKnowledge={vi.fn()}
libraryFiles={[book, sales]}
attachedFileIds={attachedFileIds}
onToggleLibraryFile={(file) =>
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(<Harness />)
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(
@@ -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) {
@@ -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(
<MemoryRouter>
<ConnectionsPage />
</MemoryRouter>,
)
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()
})
})
+167 -102
View File
@@ -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<EngineId>(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<string | null>(null)
const [connectEngine, setConnectEngine] = useState<EngineId | null>(null)
const [editing, setEditing] = useState<DatasourceRecord | null>(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<EngineId, number>()
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() {
</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>
<section className="mb-8">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-ink">{t('dataHub.addEngineTitle')}</h2>
<p className="mt-1 text-[12px] leading-5 text-ink-muted">{t('dataHub.addEngineHint')}</p>
{!canManage ? (
<p className="mt-1 text-[12px] leading-5 text-ink-muted">{t('dataHub.manageOnly')}</p>
) : null}
</div>
<div className="w-full sm:w-52">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('dataHub.engineSearch')}
aria-label={t('dataHub.engineSearch')}
leadingIcon={<Search className="h-4 w-4" />}
/>
</div>
</div>
<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
{visibleGroups.length === 0 ? (
<p className="rounded-2xl border border-dashed border-line bg-panel px-4 py-8 text-center text-sm text-ink-muted">
{t('dataHub.engineNoMatches')}
</p>
) : (
<div className="space-y-5">
{visibleGroups.map((group) => (
<div key={group.id}>
<p className="mb-2 text-[11px] font-semibold text-ink-subtle">{t(group.labelKey)}</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{group.engines.map((engine) => {
const count = countsByType.get(engine.id) || 0
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"
className="flex min-h-12 items-center gap-2.5 rounded-2xl border border-line bg-panel px-2.5 py-2 text-right shadow-card transition hover:-translate-y-0.5 hover:border-line-strong disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:translate-y-0"
>
<EngineMark id={engine.id} className="h-9 w-9" />
<span className="min-w-0 flex-1">
@@ -208,7 +203,6 @@ export function ConnectionsPage() {
<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>
)
@@ -217,8 +211,79 @@ export function ConnectionsPage() {
</div>
))}
</div>
</aside>
</div>
)}
</section>
<section>
<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-10 text-center shadow-card">
<span className="mx-auto inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-accent-soft text-accent">
<Cable className="h-5 w-5" />
</span>
<h3 className="mt-3 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>
<ConnectEngineModal
open={Boolean(connectEngine) || Boolean(editing)}
+242 -55
View File
@@ -1,10 +1,22 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { Braces, Cable, Database, FileSpreadsheet, LayoutDashboard, Network, Table2 } from 'lucide-react'
import {
ArrowLeft,
Braces,
Cable,
Database,
FileSpreadsheet,
Files,
Layers,
LayoutDashboard,
Network,
Table2,
type LucideIcon,
} 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 { isDataFileKind, isDataFilename, kindFromFilename } from '../assistant/session-files/fileKind'
import { listConnectors, type ConnectorInstance } from '../connectors/api'
import { DidarMark } from '../connectors/DidarMark'
import { listDatasourceRecords, type DatasourceRecord } from '../databases/api'
@@ -20,7 +32,16 @@ const FILE_SUGGESTIONS = [
{ 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 DATABASE_SUGGESTIONS: EngineId[] = [
'mysql',
'mariadb',
'postgresql',
'oracle',
'mssql',
'clickhouse',
'sqlite',
'duckdb',
]
const STEPS = [
{ icon: FileSpreadsheet, title: 'dataHub.stepFiles', hint: 'dataHub.stepFilesHint', to: '/data/files', wrap: 'bg-emerald-50 text-emerald-700' },
@@ -98,17 +119,49 @@ export function DataDashboardPage() {
{!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" />
<StatCard
label={t('dataHub.statFiles')}
value={files.length}
to="/data/files"
icon={Files}
iconWrap="bg-emerald-50 text-emerald-700"
wash="bg-emerald-400/25"
tone="bg-emerald-500"
/>
<StatCard
label={t('dataHub.statDatabases')}
value={databases.length}
to="/data/databases"
icon={Database}
iconWrap="bg-sky-50 text-sky-800"
wash="bg-sky-400/25"
tone="bg-sky-600"
/>
<StatCard
label={t('dataHub.statApps')}
value={apps.length}
to="/data/connectors"
icon={Cable}
iconWrap="bg-violet-50 text-violet-700"
wash="bg-violet-400/25"
tone="bg-violet-500"
/>
<StatCard
label={t('dataHub.statDiscovered')}
value={readyMaps}
to="/data/map"
icon={Network}
iconWrap="bg-accent-soft text-accent"
wash="bg-accent/20"
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>
<section className="mb-5 overflow-hidden rounded-3xl border border-line bg-panel shadow-card">
<div className="grid md:grid-cols-[minmax(0,1fr)_240px]">
<div className="p-5">
<h2 className="text-sm font-semibold text-ink">{t('dataHub.mixLabel')}</h2>
<p className="mt-1 text-[12px] text-ink-muted">
<p className="mt-1 max-w-xl text-[13px] leading-6 text-ink-muted">
{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))}` : ''}
</p>
<div className="mt-4 flex h-2.5 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-4 flex flex-wrap gap-x-5 gap-y-2 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>
{part.value ? (
<span className="tabular-nums text-ink-subtle">
{Math.round((part.value / mixTotal) * 100)}٪
</span>
) : null}
</span>
))}
</div>
</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>
<Link
to="/data/map"
className="flex items-center gap-4 border-t border-line bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.16),transparent_62%)] p-5 transition hover:bg-accent-soft/40 md:border-s md:border-t-0"
>
{mapSources ? (
<CoverageRing ready={readyMaps} total={mapSources} />
) : (
<span className="inline-flex h-[88px] w-[88px] shrink-0 items-center justify-center rounded-full bg-white text-accent shadow-card ring-1 ring-line">
<Network className="h-7 w-7" />
</span>
)}
<span className="min-w-0">
<span className="block text-sm font-semibold text-ink">{t('dataHub.statDiscovered')}</span>
<span className="mt-1 block text-[12px] leading-5 text-ink-muted">
{mapSources ? `${readyMaps} / ${mapSources}` : t('dataHub.stepMapHint')}
</span>
<span className="mt-2 inline-flex items-center gap-1 text-[12px] font-medium text-accent">
{t('dataHub.openOrgMap')}
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</span>
))}
</Link>
</div>
</section>
<div className="grid gap-4 lg:grid-cols-3">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<RecentList
title={t('dataHub.recentFiles')}
to="/data/files"
icon={Files}
iconWrap="bg-emerald-50 text-emerald-700"
total={files.length}
empty={files.length === 0}
rows={files.slice(0, 4).map((file) => ({
rows={files.slice(0, 5).map((file) => ({
id: file.id,
title: file.title?.trim() || file.name,
meta: file.original_name || file.name,
mark: <FileMark file={file} />,
}))}
/>
<RecentList
title={t('dataHub.recentDatabases')}
to="/data/databases"
icon={Database}
iconWrap="bg-sky-50 text-sky-800"
total={databases.length}
empty={databases.length === 0}
rows={databases.slice(0, 4).map((item) => ({
rows={databases.slice(0, 5).map((item) => ({
id: item.id,
title: item.name,
meta: engineById(item.type).label,
mark: <EngineMark id={item.type} className="h-9 w-9" />,
}))}
/>
<RecentList
title={t('dataHub.recentApps')}
to="/data/connectors"
icon={Cable}
iconWrap="bg-violet-50 text-violet-700"
total={apps.length}
empty={apps.length === 0}
rows={apps.slice(0, 4).map((item) => ({
rows={apps.slice(0, 5).map((item) => ({
id: item.id,
title: item.display_name,
meta: item.connector_type,
mark: <AppMark type={item.connector_type} name={item.display_name} />,
}))}
/>
</div>
@@ -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 (
<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"
className="group relative overflow-hidden rounded-2xl border border-line bg-panel p-4 shadow-card transition hover:-translate-y-0.5 hover:border-line-strong hover:shadow-[0_16px_32px_-20px_rgba(23,32,51,0.45)]"
>
<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>
<span className={`pointer-events-none absolute -top-8 -start-6 h-28 w-28 rounded-full blur-2xl ${wash}`} />
<span className="relative flex items-start justify-between gap-3">
<span className={`inline-flex h-10 w-10 items-center justify-center rounded-xl ${iconWrap}`}>
<Icon className="h-4 w-4" />
</span>
<ArrowLeft className="h-4 w-4 text-ink-subtle transition group-hover:-translate-x-0.5 group-hover:text-accent" />
</span>
<p className="relative mt-4 text-3xl font-semibold tabular-nums tracking-tight text-ink">{value}</p>
<p className="relative mt-1 text-sm text-ink-muted">{label}</p>
<span className={`absolute inset-x-4 bottom-0 h-0.5 origin-right scale-x-0 rounded-full transition group-hover:scale-x-100 ${tone}`} />
</Link>
)
}
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 (
<div className="relative h-[88px] w-[88px] shrink-0" aria-hidden>
<svg viewBox={`0 0 ${size} ${size}`} className="h-full w-full -rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
className="text-accent/15"
stroke="currentColor"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
className="text-accent"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={`${circumference * ratio} ${circumference}`}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-lg font-semibold leading-none tabular-nums text-ink">{percent}</span>
<span className="mt-0.5 text-[10px] font-medium text-ink-muted">٪</span>
</div>
</div>
)
}
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 (
<span className={`inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${meta.wrap}`}>
<Icon className="h-4 w-4" />
</span>
)
}
function AppMark({ type, name }: { type: string; name: string }) {
if (type === 'didar') return <DidarMark className="h-9 w-9" />
const letter = name.trim().slice(0, 1) || 'ا'
return (
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-violet-50 text-sm font-bold text-violet-700">
{letter}
</span>
)
}
function EmptyGuide() {
return (
<div className="space-y-5">
@@ -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 (
<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">
<section className="overflow-hidden rounded-2xl border border-line bg-panel shadow-card">
<div className="flex items-center justify-between gap-2 border-b border-line px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<span className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${iconWrap}`}>
<Icon className="h-4 w-4" />
</span>
<h2 className="truncate text-sm font-semibold text-ink">{title}</h2>
{total ? <span className="text-[12px] tabular-nums text-ink-subtle">{total}</span> : null}
</div>
<Link to={to} className="shrink-0 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">
<Link to={to} className="m-3 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">
<ul className="p-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 key={row.id}>
<Link to={to} className="flex items-center gap-3 rounded-xl px-2 py-2 transition hover:bg-surface-subtle">
{row.mark}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink">{row.title}</span>
<span className="block truncate text-[11px] text-ink-muted" dir="ltr">
{row.meta}
</span>
</span>
</Link>
</li>
))}
</ul>
+51 -6
View File
@@ -30,7 +30,11 @@ function configToValues(engineId: EngineId, config?: Record<string, unknown>): 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<string, unknown> = {}
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({
<div className="grid gap-3 sm:grid-cols-2">
{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 (
<Field
key={field}
label={t(FIELD_LABEL[field])}
required={
engine.required.includes(field) || field === 'service_name' || field === 'sid'
(engine.required.includes(field) || field === 'service_name' || field === 'sid') &&
!(isPassword && Boolean(existing))
}
hint={
isPassword && existing
@@ -220,7 +252,20 @@ export function ConnectEngineModal({
value={values[field] || ''}
onChange={(event) => 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}
/>
</Field>
@@ -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(<EngineMark id={engine.id} />)
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(<EngineMark id="opengauss" />)
expect(container.firstElementChild).toHaveAttribute('data-engine', 'openGauss')
})
})
+216 -52
View File
@@ -1,4 +1,27 @@
import type { EngineId } from './engines'
import type { ReactNode } from 'react'
import { ENGINES, type EngineId } from './engines'
const LOGOS: Record<EngineId, () => 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 (
<span className={`inline-flex shrink-0 items-center justify-center ${className}`} aria-hidden>
{engine === 'mysql' ? <MysqlLogo /> : null}
{engine === 'postgresql' ? <PostgresLogo /> : null}
{engine === 'oracle' ? <OracleLogo /> : null}
{engine === 'mssql' ? <MssqlLogo /> : null}
{engine === 'sqlite' ? <SqliteLogo /> : null}
{engine === 'neo4j' ? <Neo4jLogo /> : null}
{engine === 'mongodb' ? <MongoLogo /> : null}
{!['mysql', 'postgresql', 'oracle', 'mssql', 'sqlite', 'neo4j', 'mongodb'].includes(engine) ? (
<GenericLogo />
) : null}
<span
className={`inline-flex shrink-0 items-center justify-center ${className}`}
data-engine={resolved || 'unknown'}
aria-hidden
>
<Logo />
</span>
)
}
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 (
<svg viewBox="0 0 48 48" className="h-full w-full">
<rect width="48" height="48" rx="14" fill={fill} />
{children}
</svg>
)
}
function MysqlLogo() {
return (
<svg viewBox="0 0 48 48" className="h-full w-full">
<rect width="48" height="48" rx="14" fill="#E8F7FA" />
<Frame fill="#E8F7FA">
<path
d="M12 31c3.4-1.2 6.2-6.8 8.8-14.6.6-1.8 2.4-3 4.3-2.7 3.3.5 5.4 3.2 7.2 7.4 1.5 3.6 3.6 6.3 6.7 6.3"
d="M11 32c4-2 7-8 10-16 1-2.4 3.2-3.6 5.4-3.2 3.6.6 6 3.6 8 8.2 1.6 3.8 4 6.6 7.6 6.4"
fill="none"
stroke="#00758F"
strokeWidth="2.6"
strokeLinecap="round"
/>
<path d="M15 18.5c2.4 2.8 3.4 6.8 2.2 10.2" fill="none" stroke="#F29111" strokeWidth="2.2" strokeLinecap="round" />
<circle cx="33.5" cy="27.5" r="1.6" fill="#F29111" />
</svg>
<path d="M14 17c3 4 3.8 9.2 1.6 13" fill="none" stroke="#F29111" strokeWidth="2.2" strokeLinecap="round" />
<circle cx="34.2" cy="27.2" r="1.7" fill="#F29111" />
</Frame>
)
}
function MariaLogo() {
return (
<Frame fill="#F8F1E6">
<ellipse cx="24" cy="30" rx="11" ry="7.5" fill="#C49A6C" />
<circle cx="24" cy="20" r="7.2" fill="#A67C52" />
<ellipse cx="30.5" cy="22.5" rx="4.2" ry="2.4" fill="#C49A6C" />
<circle cx="21.4" cy="18.6" r="1.3" fill="#2C2118" />
<path d="M16 22.5c-2 .4-3.6 1.6-4 3M16 25c-2.2.8-3.4 2-3.6 3.4" fill="none" stroke="#8A6944" strokeWidth="1.4" strokeLinecap="round" />
</Frame>
)
}
function OceanBaseLogo() {
return (
<Frame fill="#FDF0EA">
<path d="M24 11 36 18v14L24 39 12 32V18Z" fill="#E65A2B" />
<path d="M24 18 31 22v8l-7 4-7-4v-8Z" fill="#FDF0EA" />
<circle cx="24" cy="24" r="3.2" fill="#E65A2B" />
</Frame>
)
}
function PostgresLogo() {
return (
<svg viewBox="0 0 48 48" className="h-full w-full">
<rect width="48" height="48" rx="14" fill="#EEF4FA" />
<ellipse cx="24" cy="27" rx="11" ry="10" fill="#336791" />
<ellipse cx="24" cy="18.5" rx="8.5" ry="7.5" fill="#3E7AAB" />
<circle cx="20.5" cy="18" r="1.6" fill="#F7FBFF" />
<circle cx="27.5" cy="18" r="1.6" fill="#F7FBFF" />
<path d="M19 32.5c1.6 2.4 8.4 2.4 10 0" fill="none" stroke="#EEF4FA" strokeWidth="1.6" strokeLinecap="round" />
<path d="M33 22c1.8 1.2 2.8 3.4 2.2 6.2" fill="none" stroke="#336791" strokeWidth="2" strokeLinecap="round" />
</svg>
<Frame fill="#EEF4FA">
<ellipse cx="24" cy="28" rx="11" ry="9.5" fill="#336791" />
<path d="M15 22c1-8 16-8 18 0 1 4-2 7-9 7s-10-3-9-7Z" fill="#3E7AAB" />
<path d="M24 13c2.4 0 4.6 1.4 5.2 3.6.4 1.4-.2 2.4-1.6 2.4H20.4c-1.4 0-2-1-1.6-2.4C19.4 14.4 21.6 13 24 13Z" fill="#336791" />
<circle cx="20.6" cy="21.2" r="1.5" fill="#F7FBFF" />
<circle cx="27.4" cy="21.2" r="1.5" fill="#F7FBFF" />
<path d="M33 24c2.2 2 3 5.2 1.6 8.4" fill="none" stroke="#336791" strokeWidth="2.2" strokeLinecap="round" />
</Frame>
)
}
function OracleLogo() {
return (
<svg viewBox="0 0 48 48" className="h-full w-full">
<rect width="48" height="48" rx="14" fill="#FDF1EF" />
<Frame fill="#FDF1EF">
<ellipse cx="24" cy="24" rx="14" ry="8.5" fill="none" stroke="#C74634" strokeWidth="3.2" />
<ellipse cx="24" cy="24" rx="7.5" ry="4.4" fill="#C74634" />
</svg>
</Frame>
)
}
function MssqlLogo() {
return (
<svg viewBox="0 0 48 48" className="h-full w-full">
<rect width="48" height="48" rx="14" fill="#FBF0F1" />
<rect x="12" y="12" width="10.5" height="10.5" rx="2" fill="#F25022" />
<rect x="25.5" y="12" width="10.5" height="10.5" rx="2" fill="#7FBA00" />
<rect x="12" y="25.5" width="10.5" height="10.5" rx="2" fill="#00A4EF" />
<rect x="25.5" y="25.5" width="10.5" height="10.5" rx="2" fill="#FFB900" />
</svg>
<Frame fill="#FBF0F1">
<ellipse cx="24" cy="15" rx="11" ry="4" fill="#A12127" />
<path d="M13 15v7c0 2.2 4.9 4 11 4s11-1.8 11-4v-7" fill="#C32F36" />
<ellipse cx="24" cy="22" rx="11" ry="4" fill="#A12127" />
<path d="M13 22v8c0 2.2 4.9 4 11 4s11-1.8 11-4v-8" fill="#7A1A1F" />
<ellipse cx="24" cy="30" rx="11" ry="4" fill="#A12127" />
</Frame>
)
}
function SqliteLogo() {
return (
<svg viewBox="0 0 48 48" className="h-full w-full">
<rect width="48" height="48" rx="14" fill="#EEF7FB" />
<Frame fill="#EEF7FB">
<path
d="M16 14h12c6 0 10 3.6 10 9s-4 9-10 9H22l-6 6V14z"
d="M18 12c6 3 10 9 12 16 1.4 5 .6 10-2 13-1.2-3.2-1.4-7.2-.4-11.2C30 22 27 16.5 21.5 13.5 19.4 12.4 17.4 12 16 12c.6 0 1.4 0 2 0Z"
fill="#0B4F6C"
/>
<path d="M20 20h10M20 25h7" stroke="#EEF7FB" strokeWidth="2" strokeLinecap="round" />
</svg>
<path d="M20 16c4 2.4 7 7 8.4 12" fill="none" stroke="#7EB7D0" strokeWidth="1.6" strokeLinecap="round" />
</Frame>
)
}
function DuckLogo() {
return (
<Frame fill="#FFFBEA">
<ellipse cx="22" cy="28" rx="11" ry="9" fill="#F5D000" />
<circle cx="30" cy="20" r="7" fill="#FFE44D" />
<path d="M36 20c3.2.2 5.6 1.6 5.6 3.2 0 1.4-2.2 2.2-5.2 2.2" fill="#F29111" />
<circle cx="32.2" cy="18.4" r="1.4" fill="#1F2937" />
</Frame>
)
}
function ClickHouseLogo() {
return (
<Frame fill="#FFF8E8">
<path d="M10 26 24 12l14 14v14H10V26Z" fill="#F5C518" />
<path d="M20 40V30h8v10" fill="#FFF8E8" />
<rect x="30" y="14" width="4.5" height="8" rx="1" fill="#D4A017" />
</Frame>
)
}
function StarRocksLogo() {
return (
<Frame fill="#EEF1FC">
<path
d="M24 10l3.4 9.2H37l-7.6 5.6 2.8 9.2L24 29.2 15.8 34l2.8-9.2L11 19.2h9.6Z"
fill="#3759D4"
/>
</Frame>
)
}
function DorisLogo() {
return (
<Frame fill="#EEF1FA">
<path
d="M24 36c-6.4 0-10-4.6-10-11 0-6 4.2-10.4 10-10.4S34 19 34 25c0 6.4-3.6 11-10 11Z"
fill="#4458C7"
/>
<path
d="M16 16c-2-4-1-8 1.4-9 1.6 2.4 3.6 4.4 6.2 5.6M32 16c2-4 1-8-1.4-9-1.6 2.4-3.6 4.4-6.2 5.6"
fill="none"
stroke="#4458C7"
strokeWidth="2.2"
strokeLinecap="round"
/>
<circle cx="21" cy="24" r="1.5" fill="#EEF1FA" />
<circle cx="27" cy="24" r="1.5" fill="#EEF1FA" />
</Frame>
)
}
function HiveLogo() {
return (
<Frame fill="#FFFBEB">
<path d="M24 10 32 15v10l-8 5-8-5V15Z" fill="#F5C518" />
<path d="M16 25 24 30l8-5v8L24 38l-8-5v-8Z" fill="#E0A800" />
<path d="M24 20 29 23v6l-5 3-5-3v-6Z" fill="#FFF6C7" />
</Frame>
)
}
function GaussLogo() {
return (
<Frame fill="#FDF0F1">
<circle cx="24" cy="24" r="13" fill="#C7000B" />
<path d="M30 24a6 6 0 1 1-1.6-4.2" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" />
<path d="M28.4 24H24" stroke="#fff" strokeWidth="3" strokeLinecap="round" />
</Frame>
)
}
function OpenGaussLogo() {
return (
<Frame fill="#FDF0F1">
<path d="M24 9 35 16v16L24 39 13 32V16Z" fill="none" stroke="#E60012" strokeWidth="2.8" />
<path d="M24 16 31 20v8l-7 4-7-4v-8Z" fill="#E60012" />
</Frame>
)
}
function VerticaLogo() {
return (
<Frame fill="#EAF2F9">
<path d="M24 12 36 36H12L24 12Z" fill="#1A5A96" />
<path d="M24 20 30.5 34h-13L24 20Z" fill="#EAF2F9" />
</Frame>
)
}
function MaxComputeLogo() {
return (
<Frame fill="#FFF3EB">
<path
d="M16 30c-4 0-6.5-2.6-6.5-6.2 0-3.2 2.2-5.6 5.2-6.2C16 13.6 20 11 24.6 11c4.8 0 8.4 3 9.2 7.2 3.6.4 6.2 3.2 6.2 6.8 0 3.8-3 6.8-7.2 6.8H16Z"
fill="#FF6A00"
/>
</Frame>
)
}
function SparkLogo() {
return (
<Frame fill="#FDF0EA">
<path
d="M24 8v32M10 24h28M14 14l20 20M34 14 14 34"
stroke="#E25A1C"
strokeWidth="3.2"
strokeLinecap="round"
/>
<circle cx="24" cy="24" r="4.2" fill="#E25A1C" />
</Frame>
)
}
function Neo4jLogo() {
return (
<svg viewBox="0 0 48 48" className="h-full w-full">
<rect width="48" height="48" rx="14" fill="#EAF8F6" />
<Frame fill="#EAF8F6">
<circle cx="16" cy="24" r="4.2" fill="#018B8B" />
<circle cx="32" cy="16.5" r="3.4" fill="#0FA3B1" />
<circle cx="33" cy="32" r="3.8" fill="#14746F" />
<path d="M19.6 22.2 29.2 17.8M19.8 26.2 30 30.6M32.2 20.2 32.6 28.2" stroke="#018B8B" strokeWidth="2" />
</svg>
</Frame>
)
}
function MongoLogo() {
return (
<svg viewBox="0 0 48 48" className="h-full w-full">
<rect width="48" height="48" rx="14" fill="#E9F8F1" />
<Frame fill="#E9F8F1">
<path
d="M24 10c1.4 4 8 8.8 8 18 0 6.2-3.2 10.4-8 12.5C19.2 38.4 16 34.2 16 28c0-9.2 6.6-14 8-18z"
fill="#00684A"
/>
<path d="M24 12c.4 8 .5 14.5 0 27" stroke="#13AA52" strokeWidth="2" strokeLinecap="round" />
</svg>
</Frame>
)
}
function GenericLogo() {
return (
<svg viewBox="0 0 48 48" className="h-full w-full">
<rect width="48" height="48" rx="14" fill="#EFF6FF" />
<Frame fill="#EFF6FF">
<ellipse cx="24" cy="16" rx="11" ry="4" fill="#2563EB" />
<path d="M13 16v12c0 2.2 4.9 4 11 4s11-1.8 11-4V16" fill="none" stroke="#2563EB" strokeWidth="2.4" />
<path d="M13 22c0 2.2 4.9 4 11 4s11-1.8 11-4" fill="none" stroke="#2563EB" strokeWidth="2.4" />
</svg>
</Frame>
)
}
+41
View File
@@ -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')
})
})
+131 -47
View File
@@ -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> = {},
): 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<string, string> {
@@ -123,7 +185,9 @@ export function defaultValues(engine: EngineDef): Record<string, string> {
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<EngineField, string> = {
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<string, string> = {
'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<string, string> = {
'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<EngineId>(['sqlite', 'duckdb', 'spark'])
+38 -22
View File
@@ -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 (
<div>
<Input
value={query}
onChange={(event) => {
setPage(1)
setQuery(event.target.value)
}}
placeholder={t('knowledge.searchChunks')}
/>
<p className="mb-3 text-[13px] leading-6 text-ink-muted">{t('knowledge.chunksLead')}</p>
<div className="max-w-md">
<Input
value={query}
onChange={(event) => {
setPage(1)
setQuery(event.target.value)
}}
placeholder={t('knowledge.searchChunks')}
leadingIcon={<Search className="h-4 w-4" />}
/>
</div>
{loading ? (
<p className="mt-4 text-sm text-ink-muted">{t('knowledge.loading')}</p>
) : items.length === 0 ? (
<p className="mt-4 text-sm text-ink-muted">{t('knowledge.chunksEmpty')}</p>
<div className="mt-4 rounded-2xl border border-dashed border-line bg-panel px-6 py-12 text-center text-sm text-ink-muted shadow-card">
{t('knowledge.chunksEmpty')}
</div>
) : (
<div className="mt-4 space-y-3">
{items.map((chunk) => (
<article key={chunk.id} className="rounded-xl border border-line bg-panel p-4">
{items.map((chunk, index) => (
<article key={chunk.id} className="rounded-2xl border border-line bg-panel p-4 shadow-card">
<p className="mb-2 text-[11px] font-medium text-ink-subtle">
{t('knowledge.chunks')} {(page - 1) * PAGE_SIZE + index + 1}
</p>
<p className="whitespace-pre-wrap text-sm leading-7 text-ink">{chunk.content}</p>
</article>
))}
</div>
)}
{total > 12 ? (
<div className="mt-4 flex items-center justify-between text-xs text-ink-muted">
<span>
{t('knowledge.page')} {page}
{total > PAGE_SIZE ? (
<div className="mt-4 flex items-center justify-between gap-3">
<span className="text-xs text-ink-muted">
{t('knowledge.page')} {page} / {pages}
</span>
<div className="flex gap-2">
<button type="button" disabled={page <= 1} onClick={() => setPage((value) => value - 1)}>
<Button variant="secondary" size="sm" disabled={page <= 1} onClick={() => setPage((value) => value - 1)}>
{t('knowledge.prev')}
</button>
<button
type="button"
disabled={page * 12 >= total}
</Button>
<Button
variant="secondary"
size="sm"
disabled={page * PAGE_SIZE >= total}
onClick={() => setPage((value) => value + 1)}
>
{t('knowledge.next')}
</button>
</Button>
</div>
</div>
) : null}
+4 -2
View File
@@ -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)),
+71 -51
View File
@@ -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 <p className="py-8 text-center text-sm text-ink-muted">{t('knowledge.documentsEmpty')}</p>
return (
<div className="rounded-3xl 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">
<FileText className="h-5 w-5" />
</span>
<h3 className="mt-4 text-base font-semibold text-ink">{t('knowledge.documentsEmptyTitle')}</h3>
<p className="mx-auto mt-1.5 max-w-md text-sm leading-6 text-ink-muted">{t('knowledge.documentsEmptyBody')}</p>
{onAdd ? (
<div className="mt-5">
<Button onClick={onAdd}>{t('knowledge.addDocument')}</Button>
</div>
) : null}
</div>
)
}
return (
<div className="overflow-hidden rounded-2xl border border-line">
<table className="w-full text-sm">
<thead className="bg-surface-subtle text-xs text-ink-muted">
<tr>
<th className="px-4 py-3 text-right font-medium">{t('knowledge.document')}</th>
<th className="px-4 py-3 text-right font-medium">{t('knowledge.type')}</th>
<th className="px-4 py-3 text-right font-medium">{t('knowledge.chunks')}</th>
<th className="px-4 py-3 text-right font-medium">{t('knowledge.status')}</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id} className="border-t border-line">
<td className="px-4 py-3">
<button type="button" className="font-medium text-ink hover:text-accent" onClick={() => onOpenChunks(doc.id)}>
{doc.name}
<ul className="overflow-hidden rounded-2xl border border-line bg-panel shadow-card">
{documents.map((doc) => {
const meta = docMark(doc.doc_type)
const Icon = meta.icon
return (
<li key={doc.id} className="border-b border-line last:border-b-0">
<div className="flex items-center gap-3 px-4 py-3">
<span className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${meta.wrap}`}>
<Icon className="h-4 w-4" />
</span>
<button type="button" className="min-w-0 flex-1 text-right" onClick={() => onOpenChunks(doc.id)}>
<span className="block truncate text-sm font-semibold text-ink hover:text-accent">{doc.name}</span>
<span className="mt-0.5 block truncate text-[12px] text-ink-muted">
{doc.doc_type} · {doc.chunk_size} {t('knowledge.chunks')}
</span>
{doc.result ? <span className="mt-1 block truncate text-xs text-danger">{doc.result}</span> : null}
</button>
<StatusBadge status={doc.status} label={documentStatusLabel(doc.status)} />
<div className="flex shrink-0 gap-1">
<button
type="button"
className="rounded-lg p-1.5 text-ink-muted transition hover:bg-accent-soft hover:text-accent"
aria-label={t('knowledge.resync')}
onClick={() => onSync(doc.id)}
>
<RefreshCw className="h-4 w-4" />
</button>
{doc.result ? <p className="mt-1 text-xs text-danger">{doc.result}</p> : null}
</td>
<td className="px-4 py-3 text-ink-muted">{doc.doc_type}</td>
<td className="px-4 py-3 text-ink-muted">{doc.chunk_size}</td>
<td className="px-4 py-3">
<StatusBadge status={doc.status} label={documentStatusLabel(doc.status)} />
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<button
type="button"
className="rounded-lg p-1.5 text-ink-muted hover:bg-accent-soft hover:text-accent"
aria-label={t('knowledge.resync')}
onClick={() => onSync(doc.id)}
>
<RefreshCw className="h-4 w-4" />
</button>
<button
type="button"
className="rounded-lg p-1.5 text-ink-muted hover:bg-danger-soft hover:text-danger"
aria-label={t('knowledge.deleteDocument')}
onClick={() => onDelete(doc.id)}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="rounded-lg p-1.5 text-ink-muted transition hover:bg-danger-soft hover:text-danger"
aria-label={t('knowledge.deleteDocument')}
onClick={() => onDelete(doc.id)}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</li>
)
})}
</ul>
)
}
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' }
}
+110 -20
View File
@@ -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 (
<PageShell>
<Link to="/knowledge" className="mb-4 inline-flex items-center gap-1 text-sm text-ink-muted hover:text-accent">
<Link
to="/knowledge"
className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-line bg-panel px-3 py-1.5 text-sm text-ink-muted shadow-card transition hover:text-accent"
>
<ArrowRight className="h-4 w-4" />
{t('knowledge.back')}
</Link>
@@ -74,16 +80,7 @@ export function KnowledgeDetail() {
icon={BookOpen}
title={space?.name || t('knowledge.title')}
subtitle={space?.description || t('knowledge.detailSubtitle')}
meta={
space ? (
<>
<StatusBadge status={space.status} label={spaceStatusLabel(space.status)} />
<span className="text-xs text-ink-muted">
{space.document_count} {t('knowledge.documents')} ? {space.chunk_count} {t('knowledge.chunks')}
</span>
</>
) : null
}
meta={space ? <StatusBadge status={space.status} label={spaceStatusLabel(space.status)} /> : null}
action={
<Button onClick={() => setAdding(true)}>
<Plus className="h-4 w-4" />
@@ -91,17 +88,45 @@ export function KnowledgeDetail() {
</Button>
}
/>
{error ? <p className="mb-4 text-sm text-danger">{error}</p> : null}
{error ? (
<p className="mb-4 rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger" role="alert">
{error}
</p>
) : null}
{loading ? (
<p className="text-sm text-ink-muted">{t('knowledge.loading')}</p>
<TableSkeleton rows={4} />
) : (
<>
<div className="mb-5 grid gap-3 sm:grid-cols-3">
<DetailMetric
label={t('knowledge.statDocuments')}
value={space?.document_count ?? documents.length}
icon={FileText}
wrap="bg-emerald-50 text-emerald-700"
active={tab === 'documents'}
onClick={() => setTab('documents')}
/>
<DetailMetric
label={t('knowledge.statChunks')}
value={space?.chunk_count ?? 0}
icon={Layers}
wrap="bg-sky-50 text-sky-800"
active={tab === 'chunks'}
onClick={() => setTab('chunks')}
/>
<DetailMetric
label={t('knowledge.statReady')}
value={readyDocs}
icon={Check}
wrap="bg-violet-50 text-violet-700"
/>
</div>
<div className="mb-5">
<SegmentedTabs
items={[
['documents', t('knowledge.documentsTab')],
['chunks', t('knowledge.chunksTab')],
['retrieve', t('knowledge.retrieveTab')],
{ id: 'documents', label: t('knowledge.documentsTab'), icon: FileText },
{ id: 'chunks', label: t('knowledge.chunksTab'), icon: Layers },
{ id: 'retrieve', label: t('knowledge.retrieveTab'), icon: Search },
]}
value={tab}
onChange={setTab}
@@ -110,6 +135,7 @@ export function KnowledgeDetail() {
{tab === 'documents' ? (
<DocumentTable
documents={documents}
onAdd={() => setAdding(true)}
onSync={(docId) => {
void syncKnowledgeDocument(id, docId)
.then(() => load())
@@ -123,10 +149,39 @@ export function KnowledgeDetail() {
/>
) : null}
{tab === 'chunks' ? (
selectedDoc ? (
<ChunkBrowser spaceId={id} documentId={selectedDoc} />
documents.length === 0 ? (
<p className="rounded-2xl border border-dashed border-line bg-panel px-6 py-12 text-center text-sm text-ink-muted shadow-card">
{t('knowledge.chunksEmpty')}
</p>
) : (
<p className="text-sm text-ink-muted">{t('knowledge.chunksEmpty')}</p>
<>
{documents.length > 1 ? (
<div className="mb-4 flex gap-2 overflow-x-auto pb-1">
{documents.map((doc) => {
const active = doc.id === selectedDoc
return (
<button
key={doc.id}
type="button"
onClick={() => setSelectedDoc(doc.id)}
className={`max-w-[220px] shrink-0 truncate rounded-full px-3 py-1.5 text-[13px] transition ${
active
? 'bg-accent-soft font-medium text-accent'
: 'bg-panel text-ink-muted ring-1 ring-line hover:text-ink'
}`}
>
{doc.name}
</button>
)
})}
</div>
) : null}
{selectedDoc ? (
<ChunkBrowser spaceId={id} documentId={selectedDoc} />
) : (
<p className="text-sm text-ink-muted">{t('knowledge.chunksEmpty')}</p>
)}
</>
)
) : null}
{tab === 'retrieve' ? <RecallTestPanel spaceId={id} /> : null}
@@ -162,3 +217,38 @@ export function KnowledgeDetail() {
</PageShell>
)
}
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 = (
<>
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-xl ${wrap}`}>
<Icon className="h-4 w-4" />
</span>
<p className="mt-3 text-2xl font-semibold tabular-nums tracking-tight text-ink">{value}</p>
<p className="mt-0.5 text-sm text-ink-muted">{label}</p>
</>
)
if (!onClick) return <div className={className}>{body}</div>
return (
<button type="button" onClick={onClick} className={className}>
{body}
</button>
)
}
+208 -61
View File
@@ -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<KnowledgeSpace[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [creating, setCreating] = useState(false)
const [createKind, setCreateKind] = useState<SourceKind | undefined>(undefined)
const [deleting, setDeleting] = useState<KnowledgeSpace | null>(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 (
<PageShell>
<PageHeader
@@ -52,78 +93,101 @@ export function KnowledgeList() {
title={t('knowledge.title')}
subtitle={t('knowledge.subtitle')}
action={
<Button onClick={() => setCreating(true)}>
<Button onClick={() => openCreate()}>
<Plus className="h-4 w-4" />
{t('knowledge.create')}
</Button>
}
/>
<div className="mb-5">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('knowledge.search')}
leadingIcon={<Search className="h-4 w-4" />}
/>
</div>
{error ? <p className="mb-4 text-sm text-danger">{error}</p> : null}
{error ? (
<p className="mb-4 rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger" role="alert">
{error}
</p>
) : null}
{loading ? (
<CardGridSkeleton />
) : filtered.length === 0 ? (
<EmptyState
icon={BookOpen}
title={t('knowledge.emptyTitle')}
description={t('knowledge.emptyDescription')}
>
<Button onClick={() => setCreating(true)}>{t('knowledge.create')}</Button>
</EmptyState>
) : items.length === 0 ? (
<EmptyGuide onCreate={openCreate} />
) : (
<div className="grid gap-3 md:grid-cols-2">
{filtered.map((item) => (
<Link
key={item.id}
to={`/knowledge/${item.id}`}
className="rounded-2xl border border-line bg-panel p-4 transition hover:border-line-strong"
>
<div className="flex items-start gap-3">
<span className="rounded-xl bg-accent-soft p-2 text-accent">
<BookOpen className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-ink">{item.name}</h3>
<StatusBadge status={item.status} label={spaceStatusLabel(item.status)} />
</div>
{item.description ? (
<p className="mt-1 line-clamp-2 text-sm text-ink-muted">{item.description}</p>
) : null}
<p className="mt-2 text-xs text-ink-subtle">
{item.document_count} {t('knowledge.documents')} ? {item.chunk_count}{' '}
{t('knowledge.chunks')}
</p>
</div>
<button
type="button"
className="rounded-lg p-1.5 text-ink-muted hover:bg-danger-soft hover:text-danger"
aria-label={t('knowledge.delete')}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setDeleting(item)
}}
<>
<div className="mb-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Metric label={t('knowledge.statSpaces')} value={items.length} icon={BookOpen} wrap="bg-accent-soft text-accent" />
<Metric label={t('knowledge.statDocuments')} value={totals.documents} icon={FileText} wrap="bg-emerald-50 text-emerald-700" />
<Metric label={t('knowledge.statChunks')} value={totals.chunks} icon={Layers} wrap="bg-sky-50 text-sky-800" />
<Metric label={t('knowledge.statReady')} value={totals.ready} icon={BookOpen} wrap="bg-violet-50 text-violet-700" />
</div>
<div className="mb-5 max-w-md">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('knowledge.search')}
leadingIcon={<Search className="h-4 w-4" />}
/>
</div>
{filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-line bg-panel px-6 py-14 text-center text-sm text-ink-muted shadow-card">
{t('knowledge.noMatches')}
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{filtered.map((item) => (
<Link
key={item.id}
to={`/knowledge/${item.id}`}
className="group relative overflow-hidden rounded-2xl border border-line bg-panel p-4 shadow-card transition hover:-translate-y-0.5 hover:border-line-strong hover:shadow-[0_16px_32px_-20px_rgba(23,32,51,0.45)]"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</Link>
))}
</div>
<span className="pointer-events-none absolute -top-8 -start-6 h-24 w-24 rounded-full bg-accent/15 blur-2xl" />
<div className="relative flex items-start gap-3">
<span className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-accent-soft text-accent">
<BookOpen className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="truncate font-semibold text-ink">{item.name}</h3>
<StatusBadge status={item.status} label={spaceStatusLabel(item.status)} />
</div>
{item.description ? (
<p className="mt-1 line-clamp-2 text-sm leading-6 text-ink-muted">{item.description}</p>
) : (
<p className="mt-1 text-sm text-ink-subtle">{t('knowledge.detailSubtitle')}</p>
)}
<p className="mt-3 flex flex-wrap gap-2 text-[12px] text-ink-muted">
<span className="rounded-full bg-surface-subtle px-2.5 py-1">
{item.document_count} {t('knowledge.documents')}
</span>
<span className="rounded-full bg-surface-subtle px-2.5 py-1">
{item.chunk_count} {t('knowledge.chunks')}
</span>
</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('knowledge.delete')}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setDeleting(item)
}}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</Link>
))}
</div>
)}
</>
)}
<CreateSpaceModal
open={creating}
initialKind={createKind}
title={t('knowledge.create')}
submitLabel={t('knowledge.create')}
onClose={() => setCreating(false)}
onClose={() => {
setCreating(false)
setCreateKind(undefined)
}}
onSubmit={async (payload) => {
await createKnowledgeSpace(payload)
await load()
@@ -148,3 +212,86 @@ export function KnowledgeList() {
</PageShell>
)
}
function Metric({
label,
value,
icon: Icon,
wrap,
}: {
label: string
value: number
icon: LucideIcon
wrap: string
}) {
return (
<div className="rounded-2xl border border-line bg-panel p-4 shadow-card">
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-xl ${wrap}`}>
<Icon className="h-4 w-4" />
</span>
<p className="mt-3 text-2xl font-semibold tabular-nums tracking-tight text-ink">{value}</p>
<p className="mt-0.5 text-sm text-ink-muted">{label}</p>
</div>
)
}
function EmptyGuide({ onCreate }: { onCreate: (kind?: SourceKind) => void }) {
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_left,rgba(37,99,235,0.14),transparent_46%),linear-gradient(180deg,#f8fbff,#ffffff)] px-5 py-6 md:px-7">
<p className="text-[11px] font-semibold text-accent">{t('knowledge.emptyKicker')}</p>
<h2 className="mt-1 text-xl font-bold tracking-tight text-ink">{t('knowledge.emptyTitle')}</h2>
<p className="mt-2 max-w-2xl text-sm leading-7 text-ink-muted">{t('knowledge.emptyDescription')}</p>
<div className="mt-5">
<Button onClick={() => onCreate()}>
<Plus className="h-4 w-4" />
{t('knowledge.create')}
</Button>
</div>
<ol className="mt-6 grid gap-3 sm:grid-cols-3">
{STEPS.map((step, index) => {
const Icon = step.icon
return (
<li key={step.title} className="flex items-start gap-3 rounded-2xl border border-line bg-white/80 p-3">
<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>
</li>
)
})}
</ol>
</div>
</section>
<section>
<h2 className="mb-3 text-sm font-semibold text-ink">{t('knowledge.suggestSources')}</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{SOURCE_CARDS.map((item) => {
const Icon = item.icon
return (
<button
key={item.id}
type="button"
onClick={() => onCreate(item.id)}
className="flex items-center gap-3 rounded-2xl border border-line bg-panel px-4 py-4 text-right shadow-card transition hover:-translate-y-0.5 hover:border-line-strong"
>
<span className={`inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl ${item.wrap}`}>
<Icon className="h-5 w-5" />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-ink">{t(item.label)}</span>
<span className="mt-0.5 block truncate text-[12px] text-ink-muted">{t(item.hint)}</span>
</span>
</button>
)
})}
</div>
</section>
</div>
)
}
+52 -26
View File
@@ -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<KnowledgeRetrieveHit[]>([])
const [busy, setBusy] = useState(false)
const [ran, setRan] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div>
<div className="grid gap-3 md:grid-cols-[1fr_120px_auto]">
<LabeledInput
label={t('knowledge.retrieveQuery')}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<LabeledInput
label={t('knowledge.topK')}
value={topK}
onChange={(event) => setTopK(event.target.value)}
/>
<div className="flex items-end">
<Button onClick={() => void run()} disabled={busy || !query.trim()}>
<form
className="rounded-2xl border border-line bg-panel p-4 shadow-card"
onSubmit={(event) => {
event.preventDefault()
void run()
}}
>
<p className="mb-4 text-[13px] leading-6 text-ink-muted">{t('knowledge.retrieveLead')}</p>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_120px_auto] md:items-end">
<LabeledInput
label={t('knowledge.retrieveQuery')}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<LabeledInput
label={t('knowledge.topK')}
value={topK}
onChange={(event) => setTopK(event.target.value)}
/>
<Button type="submit" disabled={busy || !query.trim()}>
<Search className="h-4 w-4" />
{t('knowledge.runRetrieve')}
</Button>
</div>
</div>
{error ? <p className="mt-3 text-sm text-danger">{error}</p> : null}
</form>
{error ? (
<p className="mt-3 rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger" role="alert">
{error}
</p>
) : null}
<div className="mt-4 space-y-3">
{hits.map((hit, index) => (
<article key={hit.chunk_id || index} className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex items-center justify-between text-xs text-ink-muted">
<span>{hit.doc_name || t('knowledge.unknownDoc')}</span>
<span>{hit.score.toFixed(3)}</span>
</div>
<p className="whitespace-pre-wrap text-sm leading-7 text-ink">{hit.content}</p>
</article>
))}
{!busy && hits.length === 0 ? (
<p className="text-sm text-ink-muted">{t('knowledge.retrieveEmpty')}</p>
{hits.map((hit, index) => {
const percent = hit.score >= 0 && hit.score <= 1 ? Math.round(hit.score * 100) : null
return (
<article key={hit.chunk_id || index} className="rounded-2xl border border-line bg-panel p-4 shadow-card">
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-ink-muted">
<span className="truncate font-medium text-ink">{hit.doc_name || t('knowledge.unknownDoc')}</span>
<span className="tabular-nums">{hit.score.toFixed(3)}</span>
</div>
{percent !== null ? (
<span className="mb-3 block h-1.5 overflow-hidden rounded-full bg-surface-subtle">
<span className="block h-full rounded-full bg-accent" style={{ width: `${percent}%` }} />
</span>
) : null}
<p className="whitespace-pre-wrap text-sm leading-7 text-ink">{hit.content}</p>
</article>
)
})}
{!busy && ran && hits.length === 0 ? (
<div className="rounded-2xl border border-dashed border-line bg-panel px-6 py-12 text-center text-sm text-ink-muted shadow-card">
{t('knowledge.retrieveEmpty')}
</div>
) : null}
</div>
</div>
+55 -6
View File
@@ -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',
+55 -6
View File
@@ -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: 'کشف ساختار و ارتباط‌ها در نقشه سازمان',