feat(knowledge): 展示文档处理进度并自动衔接导入索引

This commit is contained in:
2026-09-15 12:34:52 +08:00
parent b0bf7a49eb
commit fee2f4c2f3
10 changed files with 616 additions and 125 deletions

View File

@@ -3,18 +3,14 @@ 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 { CloudAgentOperations, CloudKnowledge, CloudKnowledgeFile, CloudThread, CloudAttachment } from '../../../shared/cloud-agents';
import type { CloudAgentOperations, CloudKnowledge } from '../../../shared/cloud-agents';
import { CloudKnowledgeDocuments } from './CloudKnowledgeDocuments';
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 }) {
type CloudEmbeddingModel = CloudAgentOperations['knowledge']['output']['models'][number];
const [catalog, setCatalog] = useState<{ databases: CloudKnowledge[]; models: CloudEmbeddingModel[] } | 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('');
@@ -23,15 +19,9 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
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;
@@ -45,17 +35,6 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
.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(); }
@@ -70,7 +49,7 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
{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); }}>
onChange={e => { setKbId(e.target.value); setError(''); }}>
<option value="">选择我的知识库</option>
{catalog?.databases.map(kb => <option key={kb.kb_id} value={kb.kb_id}>{kb.name}</option>)}
</select>
@@ -102,94 +81,8 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
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>}
</>}
{kbId && <CloudKnowledgeDocuments key={slug + ':' + kbId} slug={slug} kbId={kbId} busy={busy} act={act}
onDeleteKnowledge={() => { onDeleted?.(kbId); setKbId(''); setRefresh(v => v + 1); }} />}
</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>;
}

View File

@@ -0,0 +1,252 @@
import { useEffect, useRef, useState } from 'react';
import { CheckCircle2, CircleAlert, FileText, Loader2 } from 'lucide-react';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { Button } from '@/components/ui/button';
import { usePendingCloudInput } from './CloudPending';
import type { CloudKnowledgeFile, CloudThread, CloudAttachment } from '../../../shared/cloud-agents';
function documentState(file: CloudKnowledgeFile) {
const task = file.processing_task;
if (file.status === 'indexed' && file.available && file.chunk_count > 0) {
if (task && ['pending', 'running'].includes(task.status)) return {
label: '内容已可用,正在完成处理',
detail: '索引已建立,可以用于回答。后续处理完成后会自动更新。',
stage: 3, ready: true, active: true,
};
if (task && ['failed', 'cancelled'].includes(task.status)) return {
label: '内容已可用,处理未全部完成',
detail: '索引已建立,可以用于回答。' + (task.error || file.error || '后续处理未完成,请重试。'),
stage: 3, ready: true, failed: true,
};
return { label: '可用于回答', detail: '已解析并建立索引。选用此知识库后,智能体即可检索这些内容。', stage: 3, ready: true };
}
if (task?.status === 'pending') return { label: '等待处理', detail: '文档已上传,正在排队。完成后会自动更新。', stage: 1, active: true };
if (task?.status === 'running' || ['parsing', 'indexing'].includes(file.status)) {
const indexing = ['parsed', 'indexing', 'indexed'].includes(file.status);
// Terminal Task errors take precedence over a file left in an intermediate stage.
if (!task || !['failed', 'cancelled'].includes(task.status)) return {
label: indexing ? '正在建立索引' : '正在解析文档',
detail: indexing ? '正在整理可供智能体检索的内容。' : '正在读取文档文字,暂时还不能用于回答。',
stage: indexing ? 2 : 1, active: true,
};
}
if (task && ['failed', 'cancelled'].includes(task.status)) return {
label: task.status === 'cancelled' ? '处理已取消' : '处理失败',
detail: file.error || task.error || '处理未完成,可以重试;仍失败时请替换文档。',
stage: ['parsed', 'indexing', 'error_indexing', 'indexed'].includes(file.status) ? 2 : 1, failed: true,
};
if (file.status === 'indexed') return { label: '没有可用内容', detail: '未提取到可检索的文本。请替换为包含可复制文字的文档;扫描 PDF 需先识别文字。', stage: 2, empty: true };
if (['error_parsing', 'error_indexing', 'failed'].includes(file.status)) return {
label: file.status === 'error_indexing' ? '索引失败' : '解析失败',
detail: file.error || '处理未完成,请重试或替换文档。', stage: file.status === 'error_indexing' ? 2 : 1, failed: true,
};
if (file.status === 'uploaded' || file.status === 'parsed') return {
label: file.status === 'parsed' ? '等待建立索引' : '已上传,待处理',
detail: '文件已保存,完成处理后才能用于回答。', stage: file.status === 'parsed' ? 2 : 1, pending: true,
};
return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 };
}
export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge }: {
slug: string; kbId: string; busy: boolean;
act: (operation: () => Promise<void>) => Promise<void>;
onDeleteKnowledge: () => void;
}) {
const [files, setFiles] = useState<CloudKnowledgeFile[]>([]);
const [pageCount, setPageCount] = useState(1);
const [next, setNext] = useState<number | null>(null);
const [revision, setRevision] = useState(0);
const [loading, setLoading] = useState(true);
const [readError, setReadError] = useState('');
const [uploading, setUploading] = useState('');
const [submitting, setSubmitting] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [deleting, setDeleting] = useState<CloudKnowledgeFile | 'database' | null>(null);
const [processErrors, setProcessErrors] = useState<Record<string, string>>({});
const uploadIntents = useRef<Record<string, string>>({});
const processIntents = useRef<Record<string, { operationId: string; previousTaskId?: string }>>({});
const alive = useRef(true);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const refresh = () => setRevision(value => value + 1);
useEffect(() => {
let current = true;
let timer: ReturnType<typeof setTimeout>;
const load = async () => {
setLoading(true);
try {
const loaded: CloudKnowledgeFile[] = [];
let offset: number | null = 0;
for (let page = 0; page < pageCount && offset !== null; page++) {
const result: { files: CloudKnowledgeFile[]; next_offset: number | null } = await cloudAgentsApi.call('knowledgeFiles', { slug, kb_id: kbId, offset });
if (!current) return;
loaded.push(...result.files);
offset = result.next_offset;
}
if (current) {
for (const file of loaded) {
const intent = processIntents.current[file.file_id];
if (intent && file.processing_task && file.processing_task.task_id !== intent.previousTaskId) {
delete processIntents.current[file.file_id];
setProcessErrors(previous => { const value = { ...previous }; delete value[file.file_id]; return value; });
}
}
setFiles([...new Map(loaded.map(file => [file.file_id, file])).values()]);
setNext(offset); setReadError('');
}
} catch (error) {
if (current) setReadError(error instanceof Error ? error.message : '状态读取失败');
} finally {
if (current) { setLoading(false); timer = setTimeout(() => void load(), 5000); }
}
};
void load();
return () => { current = false; clearTimeout(timer); };
}, [slug, kbId, revision, pageCount]);
const process = async (file: CloudKnowledgeFile) => {
setSubmitting(file.file_id);
setProcessErrors(previous => { const value = { ...previous }; delete value[file.file_id]; return value; });
const intent = processIntents.current[file.file_id] ??= {
operationId: crypto.randomUUID(), previousTaskId: file.processing_task?.task_id,
};
try {
const task = await cloudAgentsApi.call('processKnowledge', {
slug, kb_id: kbId, file_id: file.file_id, operation_id: intent.operationId,
});
delete processIntents.current[file.file_id];
if (alive.current) setFiles(previous => previous.map(value => value.file_id === file.file_id
? { ...value, processing_task: { task_id: task.task_id, status: 'pending', error: null } } : value));
} catch (error) {
if (alive.current && processIntents.current[file.file_id] === intent) setProcessErrors(previous => ({
...previous, [file.file_id]: '文件已保存,处理请求尚未确认。' + (error instanceof Error ? error.message : '请重试处理。'),
}));
} finally {
if (alive.current) { setSubmitting(null); refresh(); }
}
};
const acceptFile = async (file: CloudKnowledgeFile) => {
if (!alive.current) return;
setFiles(previous => [file, ...previous.filter(value => value.file_id !== file.file_id)]);
await process(file);
};
const upload = (replaces?: CloudKnowledgeFile) => act(async () => {
const key = replaces?.file_id ?? 'new';
uploadIntents.current[key] ??= crypto.randomUUID();
setUploading(replaces ? '正在选择并上传替换文档…' : '正在选择并上传文档…');
try {
const file = await cloudAgentsApi.uploadKnowledge(slug, kbId, uploadIntents.current[key], replaces?.file_id);
delete uploadIntents.current[key];
if (alive.current) setUploading('');
if (file) await acceptFile(file);
} finally { if (alive.current) setUploading(''); }
});
const states = files.map(documentState);
const active = states.some(state => state.active) || submitting !== null;
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Button type="button" disabled={busy} onClick={() => void upload()}>{uploadIntents.current.new ? '重新选择同一文件并重试' : '上传文档'}</Button>
<Button type="button" variant="outline" disabled={busy} onClick={() => setImporting(value => !value)}>从对话附件导入</Button>
<Button type="button" variant="ghost" disabled={loading} onClick={refresh}>刷新状态</Button>
</div>
<p className="text-xs leading-5 text-muted-foreground">上传后自动解析并建立索引。最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。</p>
{uploading && <p role="status" className="flex items-center gap-2 text-sm"><Loader2 className="size-4 animate-spin" />{uploading}</p>}
{importing && <AttachmentImport slug={slug} kbId={kbId} busy={busy} act={act} onImported={async file => { setImporting(false); await acceptFile(file); }} />}
<div className="flex flex-wrap items-center justify-between gap-2 border-b pb-2 text-xs text-muted-foreground" aria-live="polite">
<span>{files.length ? '已显示 ' + files.length + ' 份文档 · ' + states.filter(state => state.ready).length + ' 份可用' : '知识文档'}</span>
<span>{active ? '处理中 · 状态自动更新' : '状态自动更新'}</span>
</div>
{readError && <div role="alert" className="rounded-md bg-amber-50 p-3 text-xs leading-5 text-amber-900">
状态刷新失败,以下保留上次结果,暂不能确认最新进度。{readError}
<Button type="button" size="sm" variant="ghost" disabled={loading} onClick={refresh}>重试刷新</Button>
</div>}
{!files.length && <p role="status" className="py-5 text-center text-sm text-muted-foreground">
{loading ? '正在读取文档状态…' : readError ? '暂时无法读取文档列表。' : '上传第一份文档,让智能体用你的资料回答。'}
</p>}
<div className="space-y-3">
{files.map((file, index) => {
const state = states[index];
const error = processErrors[file.file_id];
const isSubmitting = submitting === file.file_id;
return <article key={file.file_id} aria-label={file.name} className="min-w-0 space-y-3 rounded-lg border bg-background p-3">
<div className="flex items-start gap-2">
<FileText className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1"><p className="break-words font-medium [overflow-wrap:anywhere]">{file.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{file.size > 0 ? (file.size < 1024 * 1024 ? Math.ceil(file.size / 1024) + ' KB' : (file.size / 1024 / 1024).toFixed(1) + ' MB') : '大小未知'}{state.ready ? ' · ' + file.chunk_count + ' 段可检索内容' : ''}</p>
</div>
</div>
<div className={state.ready ? 'text-emerald-700' : state.failed || state.empty ? 'text-amber-800' : 'text-foreground'}>
<p className="flex items-center gap-2 text-sm font-medium" aria-live="polite">
{state.active || isSubmitting ? <Loader2 className="size-4 animate-spin" /> : state.ready ? <CheckCircle2 className="size-4" /> : state.failed || state.empty ? <CircleAlert className="size-4" /> : null}
{isSubmitting ? state.ready ? '内容已可用,正在提交处理…' : '正在提交处理…' : state.label}
</p>
</div>
<ol aria-label="处理阶段" className="flex gap-3 text-xs text-muted-foreground">
{['已上传', '解析', '索引', '可用'].map((label, step) => <li key={label} aria-current={step === state.stage ? 'step' : undefined}
className={step === state.stage ? 'font-medium text-foreground' : ''}>{step < state.stage ? '✓ ' : ''}{label}</li>)}
</ol>
<p className="text-xs leading-5 text-muted-foreground">{state.detail}</p>
{file.replaces_file_id && <p className="text-xs leading-5 text-muted-foreground">{state.ready ? '这是替换文档,新内容已可用。处理未完成时,重试可继续清理旧文档。' : '这是替换文档,新内容可用后才移除旧文档。'}</p>}
{error && !state.active && <p role="alert" className="text-xs leading-5 text-destructive">{error}</p>}
<div className="flex flex-wrap gap-1">
{(state.pending || state.failed || error) && !state.active && <Button type="button" size="sm" variant="outline" disabled={busy || !!readError} onClick={() => void act(() => process(file))}>
{state.failed || error ? '重试处理' : '开始处理'}
</Button>}
<Button type="button" size="sm" variant="ghost" disabled={busy || active || !!readError} onClick={() => void upload(file)}>
{uploadIntents.current[file.file_id] ? '重试替换' : '替换文档'}
</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy || active || !!readError} onClick={() => setDeleting(file)}>删除</Button>
</div>
</article>;
})}
</div>
{next !== null && <Button type="button" variant="ghost" disabled={loading || busy} onClick={() => setPageCount(value => value + 1)}>加载更多文档</Button>}
<div className="border-t pt-2"><Button type="button" variant="ghost" size="sm" disabled={busy || active || !!readError} onClick={() => setDeleting('database')}>删除知识库</Button></div>
{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 }); onDeleteKnowledge(); }
else await cloudAgentsApi.call('deleteKnowledgeFile', { slug, kb_id: kbId, file_id: deleting.file_id });
if (alive.current) { setDeleting(null); refresh(); }
})}>确认删除知识内容</Button><Button type="button" variant="ghost" disabled={busy} onClick={() => setDeleting(null)}>取消</Button></div>
</div>}
</div>;
}
function AttachmentImport({ slug, kbId, busy, act, onImported }: {
slug: string; kbId: string; busy: boolean; act: (operation: () => Promise<void>) => Promise<void>;
onImported: (file: CloudKnowledgeFile) => Promise<void>;
}) {
const [threads, setThreads] = useState<CloudThread[]>([]);
const [thread, setThread] = useState('');
const [files, setFiles] = useState<CloudAttachment[]>([]);
const [file, setFile] = useState('');
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(() => {
let live = true; setFiles([]); setFile('');
if (thread) 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={() => void act(async () => {
operation.current ??= crypto.randomUUID();
const result = await cloudAgentsApi.call('importKnowledgeAttachment', { slug, kb_id: kbId, thread_id: thread, attachment_id: file, operation_id: operation.current });
operation.current = null; await onImported(result);
})}>{busy ? '正在导入…' : operation.current ? '重试本次导入' : '确认导入知识库'}</Button>
{error && <p role="alert" className="text-destructive">{error}</p>}
</div>;
}