diff --git a/frontend/src/pages/DataSourcesTab.tsx b/frontend/src/pages/DataSourcesTab.tsx new file mode 100644 index 00000000..54910851 --- /dev/null +++ b/frontend/src/pages/DataSourcesTab.tsx @@ -0,0 +1,347 @@ +import { useEffect, useState } from 'react' +import { Database, Plug, RefreshCw, Table2, Trash2 } from 'lucide-react' +import { api } from '../api/client' +import { Empty, Modal, Spinner, useToast } from '../components/ui' + +/** + * 数据源管理。 + * + * 这里的每一项配置都会影响两件事:Copilot 编排时看得见什么,以及 agent 运行时 + * 能查什么。所以表单上的说明写的是"这项填错会怎样",而不是字段的字面意思—— + * Oracle 少填一个 schema,探查结果就是空的,而报错信息只会说 ORA-00942。 + */ +export function DataSourcesTab() { + const [rows, setRows] = useState([]) + const [kinds, setKinds] = useState([]) + const [loading, setLoading] = useState(true) + const [editing, setEditing] = useState(null) + const toast = useToast() + + const load = async () => { + setLoading(true) + try { + const [list, meta] = await Promise.all([api.datasources.list(), api.datasources.kinds()]) + setRows(list) + setKinds(meta.kinds ?? []) + } catch (e: any) { + toast(e.message ?? '加载失败', 'error') + } finally { + setLoading(false) + } + } + useEffect(() => { void load() }, []) + + if (loading) return
+ + return ( +
+
+
+ 接入后 Copilot 编排时就看得见这些库的结构,agent 运行时能直接查 +
+ + +
+ + {!rows.length ? ( + } + title="还没有数据源" + hint="接一个数据库,就能在「问数据」里直接提问,不用自己写 SQL" + /> + ) : ( +
+ {rows.map((row) => ( + setEditing(row)} /> + ))} +
+ )} + + {editing && ( + setEditing(null)} + onSaved={async () => { setEditing(null); await load() }} + /> + )} +
+ ) +} + +function SourceCard({ row, onChanged, onEdit }: { + row: any; onChanged: () => Promise; onEdit: () => void +}) { + const toast = useToast() + const [busy, setBusy] = useState('') + const [tables, setTables] = useState(null) + + const test = async () => { + setBusy('test') + try { + const r = await api.datasources.test(row.id) + // 失败时把驱动的原始报错给出来——这类问题九成靠错误信息定位 + toast(r.ok ? `连接正常(${r.elapsed_ms}ms)` : `连不上:${r.error}`, r.ok ? 'ok' : 'error') + } catch (e: any) { + toast(e.message ?? '测试失败', 'error') + } finally { setBusy('') } + } + + const introspect = async () => { + setBusy('introspect') + try { + const r = await api.datasources.introspect(row.id) + toast(`探到 ${r.table_count} 个对象`, r.table_count ? 'ok' : 'error') + if (!r.table_count) { + // 大概率是 schema 没配对:只读账号名下往往什么都没有 + const s = await api.datasources.schema(row.id) + toast(s.summary?.includes('schema') ? s.summary.slice(0, 120) : '没探到对象,检查 schema 配置', 'error') + } + await onChanged() + } catch (e: any) { + toast(e.message ?? '探查失败', 'error') + } finally { setBusy('') } + } + + const peek = async () => { + if (tables) { setTables(null); return } + try { + const s = await api.datasources.schema(row.id) + setTables(s.tables ?? []) + } catch (e: any) { toast(e.message ?? '读取失败', 'error') } + } + + return ( +
+
+ + {row.name} + {row.kind} + {row.readonly + ? 只读 + : 可写} + {!row.enabled && 已停用} + + + + + +
+ + {row.description && ( +
{row.description}
+ )} + +
+ + {row.kind === 'sqlite' ? row.database : `${row.username ?? ''}@${row.host ?? ''}:${row.port ?? ''}`} + + {row.options?.schema && schema {row.options.schema}} + {row.table_count ? ( + + ) : ( + 结构未探查 + )} + {row.tools?.length ? ( + {row.tools.join(' · ')} + ) : null} +
+ + {tables && ( +
+ {tables.length ? tables.join('\n') : '(空)'} +
+ )} +
+ ) +} + +function SourceEditor({ source, kinds, onClose, onSaved }: { + source: any; kinds: any[]; onClose: () => void; onSaved: () => Promise +}) { + const isNew = !source.id + const [form, setForm] = useState({ + name: '', kind: 'mysql', host: '', port: null, database: '', + username: '', password: '', description: '', readonly: true, enabled: true, + ...source, + options: { ...(source.options ?? {}) }, + }) + const [saving, setSaving] = useState(false) + const toast = useToast() + const meta = kinds.find((k) => k.value === form.kind) + const set = (patch: any) => setForm((f: any) => ({ ...f, ...patch })) + const setOption = (key: string, value: string) => + setForm((f: any) => { + const options = { ...f.options } + if (value) options[key] = value + else delete options[key] + return { ...f, options } + }) + + const save = async () => { + setSaving(true) + try { + const body: any = { ...form } + // 不传 password 表示不动;只有真填了才提交 + if (!body.password) delete body.password + if (isNew) await api.datasources.create(body) + else { + delete body.name // 名字进了工具名,改名等于换一个工具,不允许 + delete body.id + await api.datasources.update(source.id, body) + } + await onSaved() + toast(isNew ? '已添加,记得点「探查结构」' : '已保存', 'ok') + } catch (e: any) { + toast(e.message ?? '保存失败', 'error') + } finally { setSaving(false) } + } + + return ( + + + + + } + > +
+
+
+ + set({ name: e.target.value })} /> +
+ 小写英文,会成为工具名 db_query__{form.name || 'xxx'};建好不能改 +
+
+
+ + +
+
+ + {meta?.hint && ( +
+ {meta.hint} +
+ )} + + {form.kind === 'sqlite' ? ( +
+ + set({ database: e.target.value })} /> +
+ ) : ( + <> +
+
+ + set({ host: e.target.value })} /> +
+
+ + set({ port: e.target.value ? Number(e.target.value) : null })} /> +
+
+
+
+ + set({ database: e.target.value })} /> +
+
+ + setOption('schema', e.target.value)} /> +
+ 只读账号名下常常没有对象,数据在别的 schema 里 +
+
+
+
+
+ + set({ username: e.target.value })} /> +
+
+ + set({ password: e.target.value })} /> +
+
+ + )} + +
+ + set({ description: e.target.value })} /> +
+ 这句话会给 Copilot 看,它据此判断该查哪个库——写清楚里面有什么 +
+
+ + + + +
+
+ ) +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 5560b5af..7323a572 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx' import { api } from '../api/client' import { useCatalog } from '../store/catalog' import { Empty, Modal, Spinner, Tabs, useToast } from '../components/ui' +import { DataSourcesTab } from './DataSourcesTab' import type { Provider } from '../types' export function SettingsPage() { @@ -13,6 +14,7 @@ export function SettingsPage() {
{tab === 'providers' && } + {tab === 'datasources' && } {tab === 'prefs' && } {tab === 'system' && }