fix(knowledge): keep computer upload and place creation below it

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 13:39:30 +08:00
1 parent a6126c0ae2
commit a589a864d7
11 files changed
+97 -944

No files matched your search

+40 -34
View File
@@ -64,10 +64,42 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on
const selectedKnowledge = manageable.find(kb => kb.kb_id === kbId);
const managementDisabled = disabled || busy || catalogLoading || Boolean(catalogError);
const bindable = (id: string) => selectableIds === undefined || Boolean(selectableIds?.includes(id));
const creationControls = <div className="space-y-3">
<Button type="button" variant="outline" disabled={disabled || busy || documentPending || selectionFull || Boolean(createIntent.current)} onClick={() => setCreating(v => !v)}>新建知识库</Button>
{creating && <div className="space-y-3 rounded-md bg-muted/30 p-3">
<Input aria-label="知识库名称" placeholder="知识库名称" value={name} disabled={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={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>暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。</p>
<details>
<summary className="cursor-pointer font-medium text-foreground">管理员配置说明</summary>
<ol className="mt-2 list-decimal space-y-1 pl-5">
<li>Yuxi 后台「智能体管理 → 模型供应商」:启用供应商,按供应商要求填写有效的服务地址和 API Key。</li>
<li>在同一后台添加并启用 embedding 模型,填写该模型的实际向量维度。</li>
<li>配置完成后回到这里点击「刷新知识库与模型」。</li>
</ol>
</details>
</div>}
<Button type="button" disabled={disabled || busy || documentPending || (selectionFull && !createIntent.current) || !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) {
setCatalog(previous => previous ? { ...previous, databases: [kb, ...previous.databases.filter(value => value.kb_id !== kb.kb_id)] } : previous);
setKbId(kb.kb_id); setCreating(false); setName(''); setRefresh(v => v + 1); callbacks.current.onCreated(kb);
}
})}>{createIntent.current ? '重试创建' : '创建并选用'}</Button>
</div>}
{documentPending && selectedKnowledge && <p role="status" className="text-xs leading-5 text-amber-800">有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。</p>}
</div>;
return <section aria-label="上传自己的文件" className="agent-knowledge-upload space-y-4">
<div className="agent-knowledge-panel-heading"><Upload aria-hidden="true" className="size-5" /><h3>上传自己的文件</h3></div>
<div className="space-y-4 text-sm">
<p className="text-xs leading-5 text-muted-foreground">选择或新建自己的知识库,再从电脑或聊天中添加资料。选用会加入右侧配置,保存并应用后生效。</p>
<p className="text-xs leading-5 text-muted-foreground">选择或新建自己的知识库,再从电脑上传资料。选用会加入右侧配置,保存并应用后生效。</p>
<label htmlFor={targetId} className="agent-knowledge-field-label">添加到我的知识库</label>
{error && <p role="alert" className="text-destructive">{error}</p>}
{catalogError && <p role="alert" className="text-destructive">知识库与模型目录加载失败:{catalogError}。请刷新重试。</p>}
@@ -85,7 +117,6 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on
{kb.name}{selectableIds === null ? '(选用状态待确认)' : !bindable(kb.kb_id) ? '(当前暂不可选用)' : ''}
</option>)}
</select>
<Button type="button" variant="outline" disabled={disabled || busy || documentPending || selectionFull || Boolean(createIntent.current)} onClick={() => setCreating(v => !v)}>新建知识库</Button>
<Button type="button" variant="ghost" disabled={disabled || busy || catalogLoading} onClick={() => setRefresh(v => v + 1)}>刷新知识库与模型</Button>
</div>
<details className="agent-knowledge-help">
@@ -96,35 +127,6 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on
{selectedKnowledge && selectableIds !== undefined && !bindable(selectedKnowledge.kb_id) && <p role="status" className="text-xs leading-5 text-amber-800">
{selectableIds === null ? '知识库选用状态待确认。' : '这个知识库当前暂不可选用。'}你仍可管理自己的文件,刷新目录后再试。
</p>}
{creating && <div className="space-y-3 rounded-md bg-muted/30 p-3">
<Input aria-label="知识库名称" placeholder="知识库名称" value={name} disabled={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={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>暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。</p>
<details>
<summary className="cursor-pointer font-medium text-foreground">管理员配置说明</summary>
<ol className="mt-2 list-decimal space-y-1 pl-5">
<li>Yuxi 后台「智能体管理 → 模型供应商」:启用供应商,按供应商要求填写有效的服务地址和 API Key。</li>
<li>在同一后台添加并启用 embedding 模型,填写该模型的实际向量维度。</li>
<li>配置完成后回到这里点击「刷新知识库与模型」。</li>
</ol>
</details>
</div>}
<Button type="button" disabled={disabled || busy || documentPending || (selectionFull && !createIntent.current) || !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) {
setCatalog(previous => previous ? { ...previous, databases: [kb, ...previous.databases.filter(value => value.kb_id !== kb.kb_id)] } : previous);
setKbId(kb.kb_id); setCreating(false); setName(''); setRefresh(v => v + 1); callbacks.current.onCreated(kb);
}
})}>{createIntent.current ? '重试创建' : '创建并选用'}</Button>
</div>}
{documentPending && selectedKnowledge && <p role="status" className="text-xs leading-5 text-amber-800">有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。</p>}
{kbId && !selectedKnowledge && !catalogLoading && !catalogError && <div role="alert" className="space-y-2 rounded-lg border border-amber-200 p-3">
<p>这个知识库已无法管理。{documentPending ? '未确认的操作仍保留在当前页面。' : '请重新选择自己的知识库。'}</p>
<Button type="button" variant="outline" size="sm" disabled={disabled || busy} onClick={() => { setKbId(''); setDocumentPending(false); }}>
@@ -132,9 +134,13 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on
</Button>
{documentPending && <p className="text-xs leading-5 text-muted-foreground">放弃只清除本地重试请求,不会删除已保存的文件。重新进入后可确认服务器状态。</p>}
</div>}
{kbId && <div hidden={!selectedKnowledge}><CloudKnowledgeDocuments key={slug + ':' + kbId} slug={slug} kbId={kbId} kbName={selectedKnowledge?.name} busy={managementDisabled || !selectedKnowledge}
canRead={Boolean(selectedKnowledge)} act={act} onPendingChange={setDocumentPending} onRefreshKnowledge={() => setRefresh(v => v + 1)}
onDeleteKnowledge={() => { callbacks.current.onDeleted?.(kbId); setKbId(''); setDocumentPending(false); setRefresh(v => v + 1); }} /></div>}
{!selectedKnowledge && <div className="space-y-4">
<Button type="button" disabled title="请先选择或新建自己的知识库">从电脑上传</Button>
{creationControls}
</div>}
{kbId && <div hidden={!selectedKnowledge}><CloudKnowledgeDocuments key={slug + ':' + kbId} slug={slug} kbId={kbId} busy={managementDisabled || !selectedKnowledge}
canRead={Boolean(selectedKnowledge)} act={act} onPendingChange={setDocumentPending}
onDeleteKnowledge={() => { callbacks.current.onDeleted?.(kbId); setKbId(''); setDocumentPending(false); setRefresh(v => v + 1); }}>{selectedKnowledge && creationControls}</CloudKnowledgeDocuments></div>}
</div>
</section>;
}
@@ -1,280 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { CheckCircle2, FileText, Loader2, Search, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { usePendingCloudInput } from './CloudPending';
import type { CloudAttachment, CloudKnowledgeFile, CloudThread } from '../../../shared/cloud-agents';
type Selection = { key: string; threadId: string; threadTitle: string; file: CloudAttachment };
type ImportJob = Selection & {
operationId: string; status: 'queued' | 'running' | 'saved' | 'failed' | 'done';
importedFile?: CloudKnowledgeFile; error?: string;
};
function fileDetails(file: CloudAttachment) {
const extension = file.file_name.split('.').pop()?.toLowerCase() ?? '';
const type = file.file_name.includes('.') ? extension.toUpperCase() : '文件';
const size = Number.isFinite(file.file_size)
? file.file_size < 1024 ? `${file.file_size} B` : file.file_size < 1024 * 1024
? `${Math.ceil(file.file_size / 1024)} KB` : `${(file.file_size / (1024 * 1024)).toFixed(1)} MB`
: '大小未知';
const reason = file.file_size > 5 * 1024 * 1024 ? '超过 5 MB,暂不能导入'
: !/^(txt|md|csv|json|docx|xlsx|pdf)$/.test(extension) ? '此格式暂不支持知识库导入' : '';
return { type, size, reason };
}
function updatedDate(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric', year: 'numeric' }) : '更新时间未知';
}
function errorMessage(error: unknown) { return error instanceof Error ? error.message : '请稍后重试。'; }
export function CloudKnowledgeAttachmentPicker({ slug, kbId, kbName, busy, canRead = true, act, onImported, onPendingChange, onClose, onRefreshKnowledge }: {
slug: string; kbId: string; kbName: string; busy: boolean; canRead?: boolean;
act: (operation: () => Promise<void>) => Promise<void>;
onImported: (file: CloudKnowledgeFile) => Promise<void>;
onPendingChange: (pending: boolean) => void;
onClose?: () => void; onRefreshKnowledge?: () => void;
}) {
const scope = slug + ':' + kbId;
const [threads, setThreads] = useState<CloudThread[]>([]);
const [agentNames, setAgentNames] = useState<Record<string, string>>({});
const [threadId, setThreadId] = useState('');
const [threadQuery, setThreadQuery] = useState('');
const [fileQuery, setFileQuery] = useState('');
const [files, setFiles] = useState<{ threadId: string; items: CloudAttachment[] }>({ threadId: '', items: [] });
const [threadsLoading, setThreadsLoading] = useState(true);
const [filesLoading, setFilesLoading] = useState(false);
const [threadError, setThreadError] = useState('');
const [fileError, setFileError] = useState('');
const [threadsRevision, setThreadsRevision] = useState(0);
const [filesRevision, setFilesRevision] = useState(0);
const [selected, setSelected] = useState<Selection[]>([]);
const [jobs, setJobs] = useState<ImportJob[]>([]);
const [running, setRunning] = useState(false);
const [completed, setCompleted] = useState(0);
const [abandoning, setAbandoning] = useState(false);
const jobsRef = useRef<ImportJob[]>([]);
const runningRef = useRef(false);
const alive = useRef(true);
const activeScope = useRef(scope);
const permitted = useRef(canRead);
const callbacks = useRef({ onImported, onClose });
useEffect(() => { callbacks.current = { onImported, onClose }; }, [onImported, onClose]);
useEffect(() => { permitted.current = canRead; }, [canRead]);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
activeScope.current = scope;
jobsRef.current = []; runningRef.current = false;
setJobs([]); setSelected([]); setThreadId(''); setThreadQuery(''); setFileQuery(''); setCompleted(0); setRunning(false); setAbandoning(false);
}, [scope]);
const unresolved = jobs.some(job => job.status !== 'done');
const pending = running || unresolved || selected.length > 0;
const locked = busy || !canRead || running || unresolved;
usePendingCloudInput(pending);
useEffect(() => { onPendingChange(pending); return () => onPendingChange(false); }, [pending, onPendingChange]);
useEffect(() => {
let live = true;
setThreads([]); setThreadError(''); setThreadsLoading(canRead);
if (!canRead) return;
const load = async () => {
try {
let page = await cloudAgentsApi.call('threads', {});
const all = [...page.threads];
const seen = new Set<number>();
while (live && page.next_offset !== null) {
if (seen.has(page.next_offset)) throw new Error('对话列表读取未完成,请重试。');
seen.add(page.next_offset);
page = await cloudAgentsApi.call('threads', { offset: page.next_offset });
all.push(...page.threads);
}
if (live) setThreads([...new Map(all.map(thread => [thread.thread_id, thread])).values()]);
} catch (error) { if (live) setThreadError(errorMessage(error)); }
finally { if (live) setThreadsLoading(false); }
};
void load();
return () => { live = false; };
}, [scope, canRead, threadsRevision]);
useEffect(() => {
let live = true;
setAgentNames({});
if (!canRead) return;
const names: Record<string, string> = {};
const publish = () => { if (live) setAgentNames({ ...names }); };
// Names are supplementary: an unavailable agent directory must not hide chat files.
const ownNames = async () => {
let page = await cloudAgentsApi.list();
const seen = new Set<string>();
for (;;) {
for (const agent of page.agents) names[agent.slug] = agent.name;
publish();
if (!live || !page.next_cursor || seen.has(page.next_cursor)) return;
seen.add(page.next_cursor);
page = await cloudAgentsApi.list(page.next_cursor);
}
};
void ownNames().catch(() => undefined);
void cloudAgentsApi.call('received', {}).then(page => {
if (!live) return;
for (const agent of page.agents) names[agent.slug] = agent.name;
publish();
}).catch(() => undefined);
return () => { live = false; };
}, [scope, canRead, threadsRevision]);
useEffect(() => {
let live = true;
setFiles({ threadId, items: [] }); setFileError(''); setFilesLoading(Boolean(threadId) && canRead);
if (!threadId || !canRead) return;
cloudAgentsApi.call('attachments', { thread_id: threadId }).then(page => {
if (live) setFiles({ threadId, items: page.attachments });
}).catch(error => { if (live) setFileError(errorMessage(error)); })
.finally(() => { if (live) setFilesLoading(false); });
return () => { live = false; };
}, [scope, canRead, threadId, filesRevision]);
const currentThread = threads.find(thread => thread.thread_id === threadId);
const visibleThreads = threads.filter(thread => `${thread.title} ${agentNames[thread.agent_slug] ?? ''}`.toLocaleLowerCase().includes(threadQuery.trim().toLocaleLowerCase()));
const visibleFiles = files.threadId === threadId ? files.items.filter(file => file.file_name.toLocaleLowerCase().includes(fileQuery.trim().toLocaleLowerCase())) : [];
const retryJobs = jobs.filter(job => job.status !== 'done');
const failureOnly = retryJobs.length > 0 && retryJobs.every(job => job.status === 'failed');
const selectedKeys = new Set(selected.map(item => item.key));
function choose(file: CloudAttachment, checked: boolean) {
if (locked || !currentThread || filesLoading || fileError || fileDetails(file).reason) return;
const key = JSON.stringify([threadId, file.file_id]);
if (jobsRef.current.length && jobsRef.current.every(job => job.status === 'done')) { jobsRef.current = []; setJobs([]); }
setCompleted(0);
setSelected(previous => checked ? [...previous.filter(item => item.key !== key), {
key, threadId, threadTitle: currentThread.title || '未命名对话', file,
}] : previous.filter(item => item.key !== key));
}
async function importSelected() {
if (busy || !canRead || runningRef.current) return;
const attemptScope = scope;
const current = () => alive.current && activeScope.current === attemptScope;
const initialJobs: ImportJob[] = jobsRef.current.length ? jobsRef.current : selected.map(item => ({
...item, operationId: crypto.randomUUID(), status: 'queued' as const,
}));
const attempts = initialJobs.filter(job => job.status !== 'done');
if (!attempts.length) return;
const update = (key: string, value: Partial<ImportJob>) => {
jobsRef.current = jobsRef.current.map(job => job.key === key ? { ...job, ...value } : job);
setJobs([...jobsRef.current]);
};
jobsRef.current = initialJobs; setJobs([...initialJobs]);
runningRef.current = true; setRunning(true); setAbandoning(false);
try {
await act(async () => {
for (const job of attempts) {
if (!current() || !permitted.current) return;
update(job.key, { status: 'running', error: '' });
try {
const file = job.importedFile ?? await cloudAgentsApi.call('importKnowledgeAttachment', {
slug, kb_id: kbId, thread_id: job.threadId, attachment_id: job.file.file_id, operation_id: job.operationId,
});
if (!current()) return;
// A confirmed import is never repeated, even if permission changes or processing fails.
update(job.key, { status: 'saved', importedFile: file });
if (!permitted.current) return;
try {
await callbacks.current.onImported(file);
if (!current()) return;
update(job.key, { status: 'done', error: '' });
} catch (error) {
if (!current()) return;
update(job.key, { status: 'done', error: '文件已导入,处理未全部启动。请在知识库重试处理。' + errorMessage(error) });
}
} catch (error) {
if (!current()) return;
update(job.key, { status: 'failed', error: '导入结果尚未确认:' + errorMessage(error) });
}
}
if (current() && jobsRef.current.every(job => job.status === 'done')) {
setSelected([]); setCompleted(jobsRef.current.length);
if (jobsRef.current.every(job => !job.error)) {
jobsRef.current = []; setJobs([]);
callbacks.current.onClose?.();
}
}
});
} finally {
if (current()) { runningRef.current = false; setRunning(false); }
}
}
const close = () => { if (!runningRef.current && !unresolved) onClose?.(); };
return <Dialog open={canRead} onOpenChange={open => { if (!open) close(); }}>
<DialogContent className="agent-knowledge-import agent-knowledge-import-modal"
onEscapeKeyDown={event => { if (runningRef.current || unresolved) event.preventDefault(); }}
onInteractOutside={event => { if (runningRef.current || unresolved) event.preventDefault(); }}>
<div className="agent-knowledge-import-header">
<div><DialogTitle>从聊天选择文件</DialogTitle><DialogDescription>导入到「{kbName}」</DialogDescription></div>
{onClose && <Button type="button" variant="ghost" size="icon" aria-label="关闭文件选择" disabled={running || unresolved}
onClick={close}><X size={18} /></Button>}
</div>
<div className="agent-knowledge-import-content">
<p className="agent-knowledge-import-help">选择你在 MakeLore Agents 聊天中上传的文件。聊天文字和助手生成的文件不会导入。</p>
{!canRead && <p role="status">知识库管理权限待确认,文件操作已暂停;未完成请求仍保留。</p>}
{canRead && <div className="agent-knowledge-import-body">
<section className="agent-knowledge-import-chats" aria-label="来源对话">
<h3>选择对话</h3>
<div className="agent-knowledge-import-search"><Search size={16} aria-hidden="true" /><Input aria-label="搜索对话" placeholder="搜索对话或智能体" value={threadQuery} disabled={locked} onChange={event => setThreadQuery(event.target.value)} /></div>
<div className="agent-knowledge-import-chat-list">
{threadsLoading ? <p role="status">正在读取对话…</p> : threadError ? <div><p role="alert">对话读取失败:{threadError}</p><Button type="button" variant="ghost" disabled={locked} onClick={() => setThreadsRevision(value => value + 1)}>重试读取对话</Button></div>
: !threads.length ? <p>还没有个人智能体对话。</p> : !visibleThreads.length ? <p>没有匹配的对话。</p> : visibleThreads.map(thread => <button type="button" key={thread.thread_id}
className="agent-knowledge-import-chat" aria-label={'选择对话 ' + (thread.title || '未命名对话')} aria-pressed={threadId === thread.thread_id} disabled={locked}
onClick={() => { if (!locked) { setThreadId(thread.thread_id); setFileQuery(''); } }}>
<strong>{thread.title || '未命名对话'}</strong><small>{agentNames[thread.agent_slug] || '智能体名称暂不可用'} · {updatedDate(thread.updated_at)}</small>
</button>)}
</div>
</section>
<section className="agent-knowledge-import-files" aria-label="聊天中上传的文件">
<h3>{currentThread?.title || '选择文件'}</h3>
<div className="agent-knowledge-import-search"><Search size={16} aria-hidden="true" /><Input aria-label="搜索文件" placeholder="搜索文件名" value={fileQuery} disabled={locked || !threadId} onChange={event => setFileQuery(event.target.value)} /></div>
<div className="agent-knowledge-import-file-list">
{!threadId ? <p>先选择一段对话,再挑选其中的文件。</p> : filesLoading ? <p role="status">正在读取文件…</p>
: fileError ? <div><p role="alert">文件读取失败:{fileError}</p><Button type="button" variant="ghost" disabled={locked} onClick={() => setFilesRevision(value => value + 1)}>重试读取文件</Button></div>
: !files.items.length ? <p>这段对话还没有上传文件。</p> : !visibleFiles.length ? <p>没有匹配的文件。</p> : visibleFiles.map(file => {
const details = fileDetails(file), key = JSON.stringify([threadId, file.file_id]);
return <label key={file.file_id} className="agent-knowledge-import-file" data-selected={selectedKeys.has(key)} data-disabled={Boolean(details.reason)}>
<input type="checkbox" aria-label={'选择文件 ' + file.file_name} checked={selectedKeys.has(key)} disabled={locked || Boolean(details.reason)} onChange={event => choose(file, event.target.checked)} />
<FileText size={20} aria-hidden="true" /><span><strong>{file.file_name}</strong><small>{details.type} · {details.size}</small>{details.reason && <small>{details.reason}</small>}</span>
</label>;
})}
</div>
</section>
</div>}
{selected.length > 0 && <div className="agent-knowledge-import-selection"><p>已选 {selected.length} 个文件{locked ? '。' : ',可继续切换对话选择。'}</p>
<ul aria-label="已选聊天文件">{selected.map(item => <li key={item.key}><span>{item.file.file_name}<small>{item.threadTitle}</small></span>
<Button type="button" variant="ghost" size="icon" aria-label={'移除所选文件 ' + item.file.file_name} disabled={locked} onClick={() => { if (!locked) setSelected(previous => previous.filter(file => file.key !== item.key)); }}><X size={15} /></Button></li>)}</ul>
</div>}
{jobs.length > 0 && <ul className="agent-knowledge-import-results" aria-label="文件导入结果">{jobs.map(job => <li key={job.key}>
<span>{job.file.file_name}</span><span>{job.status === 'done' ? <><CheckCircle2 size={15} />已导入,处理状态见知识库</> : job.status === 'running' ? '正在导入…' : job.status === 'saved' ? '已保存,等待继续处理' : job.status === 'failed' ? '等待重试' : '等待导入'}</span>
{job.error && <p role="alert">{job.error}</p>}
</li>)}</ul>}
{abandoning && <div className="agent-knowledge-import-abandon" role="group" aria-label="放弃当前页面的重试">
<p>这只会结束当前页面的重试。已保存的文件会保留,可刷新知识库确认。</p>
<div className="flex flex-wrap gap-2"><Button type="button" variant="outline" disabled={running} onClick={() => {
if (runningRef.current) return;
jobsRef.current = []; setJobs([]); setSelected([]); setCompleted(0); setAbandoning(false);
callbacks.current.onClose?.();
}}>确认放弃重试</Button><Button type="button" variant="ghost" disabled={running} onClick={() => setAbandoning(false)}>继续保留</Button></div>
</div>}
</div>
<div className="agent-knowledge-import-footer">
<p>最大 5 MB;支持文本、Word、Excel、文本 PDF。导入后自动处理,可用于回答后才能检索。</p>
{onRefreshKnowledge && <Button type="button" variant="outline" disabled={running} onClick={() => { if (!runningRef.current) onRefreshKnowledge(); }}>刷新知识库</Button>}
{unresolved && !running && !abandoning && <Button type="button" variant="ghost" onClick={() => setAbandoning(true)}>放弃重试</Button>}
<Button type="button" disabled={busy || !canRead || running || (!selected.length && !retryJobs.length)} onClick={() => void importSelected()}>
{running ? <><Loader2 size={16} className="animate-spin" />正在导入…</> : retryJobs.length ? `${failureOnly ? '重试失败的' : '继续导入'} ${retryJobs.length} 个文件` : `导入 ${selected.length} 个文件`}
</Button>
</div>
{completed > 0 && <p role="status">已导入 {completed} 个文件,处理状态见知识库。</p>}
</DialogContent></Dialog>;
}
@@ -1,9 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, type ReactNode } 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 { CloudKnowledgeAttachmentPicker } from './CloudKnowledgeAttachmentPicker';
import type { CloudKnowledgeFile } from '../../../shared/cloud-agents';
function documentState(file: CloudKnowledgeFile) {
@@ -48,11 +47,11 @@ function documentState(file: CloudKnowledgeFile) {
return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 };
}
export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库', busy, act, onDeleteKnowledge, canRead = true, onPendingChange, onRefreshKnowledge }: {
slug: string; kbId: string; kbName?: string; busy: boolean;
export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange, children }: {
slug: string; kbId: string; busy: boolean;
act: (operation: () => Promise<void>) => Promise<void>;
onDeleteKnowledge: () => void;
canRead?: boolean; onPendingChange?: (pending: boolean) => void; onRefreshKnowledge?: () => void;
canRead?: boolean; onPendingChange?: (pending: boolean) => void; children?: ReactNode;
}) {
const [files, setFiles] = useState<CloudKnowledgeFile[]>([]);
const [pageCount, setPageCount] = useState(1);
@@ -62,8 +61,6 @@ export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库'
const [readError, setReadError] = useState('');
const [uploading, setUploading] = useState('');
const [submitting, setSubmitting] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [importPending, setImportPending] = useState(false);
const [deleting, setDeleting] = useState<CloudKnowledgeFile | 'database' | null>(null);
const [processErrors, setProcessErrors] = useState<Record<string, string>>({});
const uploadIntents = useRef<Record<string, string>>({});
@@ -72,7 +69,7 @@ export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库'
const permitted = useRef(canRead);
useEffect(() => { permitted.current = canRead; }, [canRead]);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const unresolved = Object.keys(uploadIntents.current).length > 0 || Object.keys(processIntents.current).length > 0 || importPending;
const unresolved = Object.keys(uploadIntents.current).length > 0 || Object.keys(processIntents.current).length > 0;
usePendingCloudInput(unresolved);
useEffect(() => { onPendingChange?.(unresolved); return () => onPendingChange?.(false); }, [onPendingChange, unresolved]);
const refresh = () => setRevision(value => value + 1);
@@ -156,13 +153,11 @@ export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库'
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Button type="button" disabled={busy || !canRead || (unresolved && !uploadIntents.current.new)} onClick={() => void upload()}>{uploadIntents.current.new ? '重新选择同一文件并重试' : '从电脑上传'}</Button>
<Button type="button" variant="outline" disabled={busy || !canRead || unresolved} onClick={() => setImporting(value => !value)}>从聊天选择文件</Button>
<Button type="button" variant="ghost" disabled={loading || !canRead} onClick={refresh}>刷新状态</Button>
</div>
{children}
<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 && <CloudKnowledgeAttachmentPicker slug={slug} kbId={kbId} kbName={kbName} busy={busy} canRead={canRead}
act={act} onPendingChange={setImportPending} onImported={acceptFile} onClose={() => setImporting(false)} onRefreshKnowledge={onRefreshKnowledge} />}
<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>
+1 -51
View File
@@ -55,51 +55,6 @@
.agent-knowledge-help summary:focus-visible { outline: 2px solid #9a7bb5; outline-offset: 3px; border-radius: 4px; }
.agent-knowledge-selection ul { max-height: 480px; overflow: auto; overscroll-behavior: contain; }
.agent-knowledge-selection button { flex-shrink: 0; }
.agent-knowledge-import.agent-knowledge-import-modal { display: flex; flex-direction: column; width: calc(100vw - 32px); max-width: 860px; max-height: calc(100dvh - 32px); gap: 18px; border-radius: 22px; padding: 24px; background: #fffdf9; color: #273246; }
.agent-knowledge-import-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-shrink: 0; min-width: 0; }
.agent-knowledge-import-header > div { min-width: 0; overflow-wrap: anywhere; }
.agent-knowledge-import-header p { margin-top: 6px; line-height: 1.7; color: #59657a; }
.agent-knowledge-import-content { display: flex; flex-direction: column; gap: 16px; min-height: 0; min-width: 0; overflow: auto; overscroll-behavior: contain; }
.agent-knowledge-import-help { color: #606c7e; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
.agent-knowledge-import-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 16px; min-height: 0; min-width: 0; overflow: auto; }
.agent-knowledge-import-content > .agent-knowledge-import-body { flex-shrink: 0; }
.agent-knowledge-import-chats, .agent-knowledge-import-files { display: flex; flex-direction: column; min-width: 0; min-height: 0; gap: 10px; }
.agent-knowledge-import-chats h3, .agent-knowledge-import-files h3 { font-size: 14px; font-weight: 600; }
.agent-knowledge-import-search { display: flex; align-items: center; gap: 8px; min-width: 0; color: #606c7e; }
.agent-knowledge-import-search svg, .agent-knowledge-import-file > svg { flex-shrink: 0; }
.agent-knowledge-import :is(input:not([type="checkbox"]), select) { width: 100%; min-width: 0; min-height: 42px; border: 1px solid #cdd5e3; border-radius: 11px; background: #fff; padding: 10px 12px; font-size: 14px; }
.agent-knowledge-import :is(input, button):focus-visible { outline: 2px solid #9a7bb5; outline-offset: 2px; }
.agent-knowledge-import-chat-list, .agent-knowledge-import-file-list { min-width: 0; overflow: auto; max-height: 330px; overscroll-behavior: contain; border: 1px solid #dce2ec; border-radius: 13px; background: #fff; }
.agent-knowledge-import-chat-list > :is(p, div), .agent-knowledge-import-file-list > :is(p, div) { padding: 14px; color: #606c7e; font-size: 13px; line-height: 1.7; }
.agent-knowledge-import-chat { display: flex; flex-direction: column; gap: 5px; width: 100%; min-height: 68px; padding: 12px; text-align: left; font-size: 14px; line-height: 1.5; cursor: pointer; overflow-wrap: anywhere; transition: background-color 160ms ease; }
.agent-knowledge-import-chat + .agent-knowledge-import-chat { border-top: 1px solid #edf0f5; }
.agent-knowledge-import-chat:hover { background: #f6f5fa; }
.agent-knowledge-import-chat[aria-pressed="true"] { background: #f1f6ff; box-shadow: inset 3px 0 #6b87b3; }
.agent-knowledge-import-chat:disabled { cursor: not-allowed; }
.agent-knowledge-import-chat small { color: #606c7e; font-size: 12px; }
.agent-knowledge-import-file { display: flex; align-items: flex-start; gap: 10px; min-width: 0; min-height: 64px; padding: 12px; font-size: 14px; line-height: 1.6; cursor: pointer; overflow-wrap: anywhere; }
.agent-knowledge-import-file + .agent-knowledge-import-file { border-top: 1px solid #edf0f5; }
.agent-knowledge-import-file:has(input:checked) { background: #f6f2ff; }
.agent-knowledge-import-file:has(input:disabled) { cursor: not-allowed; }
.agent-knowledge-import-file input[type="checkbox"] { width: 17px; height: 17px; margin-top: 3px; accent-color: #79629d; flex-shrink: 0; }
.agent-knowledge-import-file > span { min-width: 0; }
.agent-knowledge-import-file small { display: block; font-size: 12px; color: #606c7e; }
.agent-knowledge-import-selection { flex-shrink: 0; min-width: 0; border-radius: 12px; background: #f3f0f8; padding: 12px; font-size: 13px; overflow-wrap: anywhere; }
.agent-knowledge-import-selection ul { max-height: 130px; overflow: auto; }
.agent-knowledge-import-selection li { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-top: 8px; }
.agent-knowledge-import-selection li > span { min-width: 0; }
.agent-knowledge-import-selection li button { flex-shrink: 0; }
.agent-knowledge-import-selection small { display: block; color: #606c7e; font-size: 12px; }
.agent-knowledge-import-results { max-height: 170px; min-height: 0; flex-shrink: 0; overflow: auto; border: 1px solid #dce5e0; border-radius: 12px; padding: 12px; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
.agent-knowledge-import-results li + li { border-top: 1px solid #e8e6e1; margin-top: 10px; padding-top: 10px; }
.agent-knowledge-import-results li > span { display: flex; align-items: center; gap: 6px; }
.agent-knowledge-import-results li > span + span { color: #526c5f; }
.agent-knowledge-import-results [role="alert"] { color: #b54532; }
.agent-knowledge-import-abandon { flex-shrink: 0; padding: 12px; border: 1px solid #efd9ba; border-radius: 12px; background: #fff5e9; font-size: 13px; line-height: 1.7; }
.agent-knowledge-import-abandon p { margin-bottom: 10px; }
.agent-knowledge-import-abandon button { margin: 0 8px 6px 0; }
.agent-knowledge-import-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 9px; flex-shrink: 0; }
.agent-knowledge-import-footer p { flex: 1 1 280px; min-width: 0; font-size: 12px; line-height: 1.7; color: #606c7e; }
@container (max-width: 620px) { .agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 640px) {
.agent-editor-tabs { padding: 10px 12px; }
@@ -114,10 +69,5 @@
.agent-config-section-top > button { margin-left: 48px; }
.agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); gap: 14px; }
.agent-knowledge-upload, .agent-knowledge-selection { padding: 14px; border-radius: 14px; }
.agent-knowledge-import.agent-knowledge-import-modal { padding: 18px; border-radius: 18px; gap: 14px; }
.agent-knowledge-import-body { grid-template-columns: minmax(0, 1fr); }
.agent-knowledge-import-chat-list { max-height: 160px; }
.agent-knowledge-import-file-list { max-height: 240px; }
.agent-knowledge-import-footer button { width: 100%; }
}
@media (prefers-reduced-motion: reduce) { .agent-resource-option, .agent-knowledge-import-chat { transition: none; } }
@media (prefers-reduced-motion: reduce) { .agent-resource-option { transition: none; } }