195 lines
15 KiB
TypeScript
195 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { usePendingCloudInput } from './CloudPending';
|
||
import type { CloudKnowledge, CloudKnowledgeFile, CloudThread, CloudAttachment } from '../../../shared/cloud-agents';
|
||
|
||
const statusName: Record<string, string> = {
|
||
uploaded: '待解析', parsing: '解析中', parsed: '待索引', indexing: '索引中', indexed: '可检索',
|
||
error_parsing: '解析失败', error_indexing: '索引失败',
|
||
};
|
||
export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: string; onCreated: (kb: CloudKnowledge) => void; onDeleted?: (id: string) => void }) {
|
||
const [catalog, setCatalog] = useState<{ databases: CloudKnowledge[]; models: { id: string; name: string }[] } | null>(null);
|
||
const [kbId, setKbId] = useState('');
|
||
const [files, setFiles] = useState<CloudKnowledgeFile[]>([]);
|
||
const [next, setNext] = useState<number | null>(null);
|
||
const [name, setName] = useState('');
|
||
const [model, setModel] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [catalogError, setCatalogError] = useState('');
|
||
const [catalogLoading, setCatalogLoading] = useState(true);
|
||
const [busy, setBusy] = useState(false);
|
||
const [refresh, setRefresh] = useState(0);
|
||
const [creating, setCreating] = useState(false);
|
||
const [deleting, setDeleting] = useState<CloudKnowledgeFile | 'database' | null>(null);
|
||
const [importing, setImporting] = useState(false);
|
||
const replacements = useRef<Record<string, string>>({});
|
||
usePendingCloudInput(busy || (creating && Boolean(name.trim())));
|
||
const createIntent = useRef<{ operation_id: string; name: string; embedding_model: string } | null>(null);
|
||
const uploadIntent = useRef<string | null>(null);
|
||
const processIntents = useRef<Record<string, string>>({});
|
||
const alive = useRef(true);
|
||
const paged = useRef(false);
|
||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||
useEffect(() => {
|
||
let current = true;
|
||
setCatalogLoading(true); setCatalogError('');
|
||
cloudAgentsApi.call('knowledge', { slug }).then(v => {
|
||
if (!current) return;
|
||
setCatalog(v);
|
||
if (!createIntent.current) setModel(previous => v.models.some(item => item.id === previous) ? previous : '');
|
||
})
|
||
.catch(e => { if (current) setCatalogError(String(e.message || e)); })
|
||
.finally(() => { if (current) setCatalogLoading(false); });
|
||
return () => { current = false; };
|
||
}, [slug, refresh]);
|
||
useEffect(() => {
|
||
if (!kbId) { setFiles([]); return; }
|
||
paged.current = false;
|
||
let current = true;
|
||
const load = () => cloudAgentsApi.call('knowledgeFiles', { slug, kb_id: kbId })
|
||
.then(v => { if (current) { setFiles(v.files); setNext(v.next_offset); } })
|
||
.catch(e => { if (current) setError(String(e.message || e)); });
|
||
void load();
|
||
const timer = setInterval(() => { if (!paged.current) void load(); }, 5000);
|
||
return () => { current = false; clearInterval(timer); };
|
||
}, [slug, kbId, refresh]); // A refreshed first page replaces pagination only after an explicit operation.
|
||
const act = async (operation: () => Promise<void>) => {
|
||
setBusy(true); setError('');
|
||
try { await operation(); }
|
||
catch(e) { if (alive.current) setError(e instanceof Error ? e.message : '知识库操作失败'); }
|
||
finally { if (alive.current) setBusy(false); }
|
||
};
|
||
return <details className="rounded-lg border p-4">
|
||
<summary className="min-h-8 cursor-pointer text-sm font-medium">管理我的知识文档</summary>
|
||
<div className="mt-4 space-y-4 text-sm">
|
||
<p className="text-xs leading-5 text-muted-foreground">上传到知识库的文档可供获准使用此智能体的人检索。解析后建立索引才会生效,向量调用扣除你的词元点数。</p>
|
||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||
{catalogError && <p role="alert" className="text-destructive">知识库与模型目录加载失败:{catalogError}。请刷新重试。</p>}
|
||
<div className="flex flex-wrap gap-2">
|
||
<select aria-label="管理的知识库" className="h-10 min-w-0 flex-1 basis-48 rounded-md border bg-background px-3" value={kbId} disabled={busy}
|
||
onChange={e => { setKbId(e.target.value); uploadIntent.current = null; setNext(null); }}>
|
||
<option value="">选择我的知识库</option>
|
||
{catalog?.databases.map(kb => <option key={kb.kb_id} value={kb.kb_id}>{kb.name}</option>)}
|
||
</select>
|
||
<Button type="button" variant="outline" disabled={busy} onClick={() => setCreating(v => !v)}>新建知识库</Button>
|
||
<Button type="button" variant="ghost" disabled={busy || catalogLoading} onClick={() => setRefresh(v => v + 1)}>刷新知识库与模型</Button>
|
||
</div>
|
||
{creating && <div className="space-y-3 rounded-md bg-muted/30 p-3">
|
||
<Input aria-label="知识库名称" placeholder="知识库名称" value={name} disabled={busy || !!createIntent.current} onChange={e => setName(e.target.value)} />
|
||
<select aria-label="向量模型" className="h-10 w-full rounded-md border bg-background px-3" value={model}
|
||
disabled={busy || !!createIntent.current || catalogLoading || !!catalogError || !catalog?.models.length} onChange={e => setModel(e.target.value)}>
|
||
<option value="">{catalogLoading ? '正在加载向量模型…' : catalogError ? '向量模型加载失败' : !catalog?.models.length ? '暂无可用向量模型' : '选择向量模型'}</option>
|
||
{catalog?.models.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||
</select>
|
||
{!catalogLoading && !catalogError && catalog && !catalog.models.length && <div className="space-y-2 text-xs leading-5 text-muted-foreground">
|
||
<p>暂时没有可用的向量模型。请联系平台管理员完成云端配置,然后刷新。</p>
|
||
<details>
|
||
<summary className="cursor-pointer font-medium text-foreground">管理员配置说明</summary>
|
||
<ol className="mt-2 list-decimal space-y-1 pl-5">
|
||
<li>Yuxi 后台「智能体管理 → 模型供应商」:启用供应商,添加并启用 embedding 模型,填写模型实际向量维度。</li>
|
||
<li>Works Square 运营后台「模型管理」:从 one-api 刷新,激活同一模型并保存。</li>
|
||
<li>两端模型 ID 必须完全一致,one-api 需支持该模型的向量接口。配置完成后点击「刷新知识库与模型」。</li>
|
||
</ol>
|
||
</details>
|
||
</div>}
|
||
<Button type="button" disabled={busy || !name.trim() || (!createIntent.current && (catalogLoading || !!catalogError || !catalog?.models.some(item => item.id === model)))} onClick={() => void act(async () => {
|
||
createIntent.current ??= { operation_id: crypto.randomUUID(), name: name.trim(), embedding_model: model };
|
||
const kb = await cloudAgentsApi.call('createKnowledge', { slug, ...createIntent.current });
|
||
createIntent.current = null;
|
||
if (alive.current) { setKbId(kb.kb_id); setCreating(false); setName(''); setRefresh(v => v + 1); onCreated(kb); }
|
||
})}>{createIntent.current ? '重试创建' : '创建并选用'}</Button>
|
||
</div>}
|
||
{kbId && <>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button type="button" disabled={busy} onClick={() => void act(async () => {
|
||
uploadIntent.current ??= crypto.randomUUID();
|
||
const uploaded = await cloudAgentsApi.uploadKnowledge(slug, kbId, uploadIntent.current);
|
||
if (!uploaded) return;
|
||
uploadIntent.current = null;
|
||
if (alive.current) setRefresh(v => v + 1);
|
||
})}>{uploadIntent.current ? '重新选择同一文件并重试' : '上传文档'}</Button>
|
||
<Button type="button" variant="ghost" disabled={busy} onClick={() => setRefresh(v => v + 1)}>刷新状态</Button>
|
||
<Button type="button" variant="outline" disabled={busy} onClick={() => setImporting(v => !v)}>从对话附件导入</Button>
|
||
<Button type="button" variant="ghost" disabled={busy} onClick={() => setDeleting('database')}>删除知识库</Button>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。</p>
|
||
{importing && <AttachmentImport key={kbId} slug={slug} kbId={kbId} onImported={() => { setImporting(false); setRefresh(v => v + 1); }} />}
|
||
{deleting && <div role="dialog" aria-label="删除知识内容" className="space-y-3 rounded-lg border border-amber-200 p-3">
|
||
<p>{deleting === 'database' ? '删除整个知识库及其文档、索引?所有使用此库的智能体将无法继续检索这些内容。' : `删除文档“${deleting.name}”及其索引?`}</p>
|
||
<div className="flex gap-2"><Button type="button" variant="outline" disabled={busy} onClick={() => void act(async () => {
|
||
if (deleting === 'database') { await cloudAgentsApi.call('deleteKnowledge', { slug, kb_id: kbId }); onDeleted?.(kbId); setKbId(''); }
|
||
else await cloudAgentsApi.call('deleteKnowledgeFile', { slug, kb_id: kbId, file_id: deleting.file_id });
|
||
setDeleting(null); setRefresh(v => v + 1);
|
||
})}>确认删除知识内容</Button><Button type="button" variant="ghost" disabled={busy} onClick={() => setDeleting(null)}>取消</Button></div>
|
||
</div>}
|
||
<div className="divide-y">
|
||
{files.map(file => <div key={file.file_id} className="flex items-start justify-between gap-3 py-3">
|
||
<div className="min-w-0"><p className="break-all">{file.name}</p><p className="mt-1 text-xs text-muted-foreground">{statusName[file.status] || file.status} · {file.chunk_count || 0} 个分块</p>
|
||
{file.error && <p className="mt-1 text-xs text-destructive">{file.error}</p>}</div>
|
||
<div className="flex shrink-0 flex-wrap gap-1">{!['parsing', 'indexing', 'indexed'].includes(file.status) && <Button type="button" size="sm" variant="outline" disabled={busy}
|
||
onClick={() => void act(async () => {
|
||
processIntents.current[file.file_id] ??= crypto.randomUUID();
|
||
await cloudAgentsApi.call('processKnowledge', { slug, kb_id: kbId, file_id: file.file_id, operation_id: processIntents.current[file.file_id] });
|
||
delete processIntents.current[file.file_id];
|
||
if (alive.current) setRefresh(v => v + 1);
|
||
})}>{file.status.startsWith('error') ? '重试处理' : '解析并索引'}</Button>}
|
||
<Button type="button" size="sm" variant="ghost" disabled={busy || ['parsing', 'indexing'].includes(file.status)} onClick={() => void act(async () => {
|
||
replacements.current[file.file_id] ??= crypto.randomUUID();
|
||
const result = await cloudAgentsApi.uploadKnowledge(slug, kbId, replacements.current[file.file_id], file.file_id);
|
||
if (result) { delete replacements.current[file.file_id]; setRefresh(v => v + 1); }
|
||
})}>{replacements.current[file.file_id] ? '重试替换' : '上传替换文档'}</Button>
|
||
<Button type="button" size="sm" variant="ghost" disabled={busy || ['parsing', 'indexing'].includes(file.status)} onClick={() => setDeleting(file)}>删除文档</Button>
|
||
</div>
|
||
</div>)}
|
||
{!files.length && <p className="py-4 text-xs text-muted-foreground">尚未上传文档。</p>}
|
||
</div>
|
||
<p className="text-xs leading-5 text-muted-foreground">替换文档上传后,需对新文档点击“解析并索引”。新索引成功后才移除旧文档;失败时旧文档仍可检索。</p>
|
||
{next !== null && <Button type="button" variant="ghost" disabled={busy} onClick={() => void act(async () => {
|
||
paged.current = true;
|
||
const page = await cloudAgentsApi.call('knowledgeFiles', { slug, kb_id: kbId, offset: next });
|
||
if (alive.current) { setFiles(v => [...v, ...page.files]); setNext(page.next_offset); }
|
||
})}>加载更多文档</Button>}
|
||
</>}
|
||
</div>
|
||
</details>;
|
||
}
|
||
|
||
function AttachmentImport({ slug, kbId, onImported }: { slug: string; kbId: string; onImported: () => void }) {
|
||
const [threads, setThreads] = useState<CloudThread[]>([]);
|
||
const [thread, setThread] = useState('');
|
||
const [files, setFiles] = useState<CloudAttachment[]>([]);
|
||
const [file, setFile] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const operation = useRef<string | null>(null);
|
||
usePendingCloudInput(busy || Boolean(operation.current));
|
||
useEffect(() => {
|
||
let live = true;
|
||
const load = async () => {
|
||
let page = await cloudAgentsApi.call('threads', {});
|
||
while (page.next_offset !== null) { const next = await cloudAgentsApi.call('threads', { offset: page.next_offset }); page = { ...next, threads: [...page.threads, ...next.threads] }; }
|
||
if (live) setThreads(page.threads);
|
||
};
|
||
void load().catch(e => { if (live) setError(e instanceof Error ? e.message : '对话读取失败'); });
|
||
return () => { live = false; };
|
||
}, []);
|
||
useEffect(() => {
|
||
if (!thread) return;
|
||
let live = true; setFiles([]); setFile('');
|
||
cloudAgentsApi.call('attachments', { thread_id: thread }).then(page => { if (live) setFiles(page.attachments); }).catch(e => { if (live) setError(e instanceof Error ? e.message : '附件读取失败'); });
|
||
return () => { live = false; };
|
||
}, [thread]);
|
||
return <div className="space-y-3 rounded-lg border p-3"><p className="text-xs text-muted-foreground">只显示你自己的对话附件。导入后,该文档可以被使用此知识库的智能体检索。</p>
|
||
<select aria-label="附件来源对话" className="h-10 w-full rounded-md border bg-background px-3" value={thread} disabled={busy || Boolean(operation.current)} onChange={e => setThread(e.target.value)}><option value="">选择对话</option>{threads.map(item => <option key={item.thread_id} value={item.thread_id}>{item.title || '未命名对话'}</option>)}</select>
|
||
<select aria-label="入库附件" className="h-10 w-full rounded-md border bg-background px-3" value={file} disabled={busy || Boolean(operation.current)} onChange={e => setFile(e.target.value)}><option value="">选择附件</option>{files.map(item => <option key={item.file_id} value={item.file_id}>{item.file_name}</option>)}</select>
|
||
<Button type="button" disabled={busy || !thread || !file} onClick={async () => {
|
||
operation.current ??= crypto.randomUUID(); setBusy(true); setError('');
|
||
try { await cloudAgentsApi.call('importKnowledgeAttachment', { slug, kb_id: kbId, thread_id: thread, attachment_id: file, operation_id: operation.current }); onImported(); }
|
||
catch (e) { setError(e instanceof Error ? e.message : '导入失败'); } finally { setBusy(false); }
|
||
}}>{operation.current ? '重试本次导入' : '确认导入知识库'}</Button>
|
||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||
</div>;
|
||
}
|