Files
makelore/src/pages/CloudAgents/CloudKnowledge.tsx

195 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>;
}