feat(agents): add grouped knowledge selection and personal uploads
This commit is contained in:
1 parent
a324a3ad0d
commit
3786a18b43
12 files changed
+763
-54
No files matched your search
@@ -7,7 +7,14 @@ import type { CloudAgentOperations, CloudKnowledge } from '../../../shared/cloud
|
||||
|
||||
import { CloudKnowledgeDocuments } from './CloudKnowledgeDocuments';
|
||||
|
||||
export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: string; onCreated: (kb: CloudKnowledge) => void; onDeleted?: (id: string) => void }) {
|
||||
export interface CloudOwnedKnowledgeCatalog { databases: CloudKnowledge[] | null; loading: boolean; error: string }
|
||||
|
||||
export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, onCatalog, refreshToken = 0, disabled = false, selectionFull = false, selectedIds = [], selectableIds }: {
|
||||
slug: string; onCreated: (kb: CloudKnowledge) => void; onDeleted?: (id: string) => void;
|
||||
onCatalog?: (value: CloudOwnedKnowledgeCatalog) => void; refreshToken?: number; disabled?: boolean;
|
||||
selectionFull?: boolean; selectedIds?: string[];
|
||||
selectableIds?: string[] | null; onSelected?: (kb: CloudKnowledge) => void;
|
||||
}) {
|
||||
type CloudEmbeddingModel = CloudAgentOperations['knowledge']['output']['models'][number];
|
||||
const [catalog, setCatalog] = useState<{ databases: CloudKnowledge[]; models: CloudEmbeddingModel[] } | null>(null);
|
||||
const [kbId, setKbId] = useState('');
|
||||
@@ -19,10 +26,17 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [creating, setCreating] = useState(false);
|
||||
usePendingCloudInput(busy || (creating && Boolean(name.trim())));
|
||||
const [documentPending, setDocumentPending] = useState(false);
|
||||
const createIntent = useRef<{ operation_id: string; name: string; embedding_model: string } | null>(null);
|
||||
usePendingCloudInput(busy || documentPending || Boolean(createIntent.current) || (creating && Boolean(name.trim())));
|
||||
const operationInFlight = useRef(false);
|
||||
const callbacks = useRef({ onCreated, onDeleted });
|
||||
useEffect(() => { callbacks.current = { onCreated, onDeleted }; }, [onCreated, onDeleted]);
|
||||
const alive = useRef(true);
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||
useEffect(() => {
|
||||
onCatalog?.({ databases: catalogLoading || catalogError ? null : catalog?.databases ?? null, loading: catalogLoading, error: catalogError });
|
||||
}, [catalog, catalogLoading, catalogError, onCatalog]);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setCatalogLoading(true); setCatalogError('');
|
||||
@@ -34,32 +48,52 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
|
||||
.catch(e => { if (current) setCatalogError(String(e.message || e)); })
|
||||
.finally(() => { if (current) setCatalogLoading(false); });
|
||||
return () => { current = false; };
|
||||
}, [slug, refresh]);
|
||||
}, [slug, refresh, refreshToken]);
|
||||
const act = async (operation: () => Promise<void>) => {
|
||||
if (disabled || operationInFlight.current) return;
|
||||
operationInFlight.current = true;
|
||||
setBusy(true); setError('');
|
||||
try { await operation(); }
|
||||
catch(e) { if (alive.current) setError(e instanceof Error ? e.message : '知识库操作失败'); }
|
||||
finally { if (alive.current) setBusy(false); }
|
||||
finally { operationInFlight.current = false; if (alive.current) setBusy(false); }
|
||||
};
|
||||
// This personal facade endpoint authoritatively lists libraries owned by the current account.
|
||||
const manageable = catalog?.databases ?? [];
|
||||
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));
|
||||
return <details className="rounded-lg border p-4">
|
||||
<summary className="min-h-8 cursor-pointer text-sm font-medium">管理我的知识文档</summary>
|
||||
<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>
|
||||
<p className="text-xs leading-5 text-muted-foreground">上传到知识库的文档可供获准使用此智能体的人检索。解析并建立索引后才会生效;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限。知识处理如触发对话模型调用,仍按原模型实际用量计费。</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); setError(''); }}>
|
||||
<option value="">选择我的知识库</option>
|
||||
{catalog?.databases.map(kb => <option key={kb.kb_id} value={kb.kb_id}>{kb.name}</option>)}
|
||||
<select aria-label="管理的知识库" className="h-10 min-w-0 flex-1 basis-48 rounded-md border bg-background px-3" value={kbId} disabled={managementDisabled || documentPending}
|
||||
onChange={e => {
|
||||
const selected = manageable.find(kb => kb.kb_id === e.target.value);
|
||||
if (managementDisabled || documentPending || (selected && bindable(selected.kb_id) && selectionFull && !selectedIds.includes(selected.kb_id))) return;
|
||||
setKbId(selected?.kb_id ?? ''); setError('');
|
||||
if (selected) (onSelected ?? onCreated)(selected);
|
||||
}}>
|
||||
<option value="">{catalogLoading ? '正在读取我的知识库…' : '选择我的知识库'}</option>
|
||||
{kbId && !selectedKnowledge && <option value={kbId} disabled>知识库管理权限待确认</option>}
|
||||
{manageable.map(kb => <option key={kb.kb_id} value={kb.kb_id} disabled={bindable(kb.kb_id) && selectionFull && !selectedIds.includes(kb.kb_id)}>
|
||||
{kb.name}{selectableIds === null ? '(选用状态待确认)' : !bindable(kb.kb_id) ? '(当前暂不可选用)' : ''}
|
||||
</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>
|
||||
<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>
|
||||
{!catalogLoading && !catalogError && !manageable.length && <p className="text-xs leading-5 text-muted-foreground">还没有可管理的知识库,可以新建一个保存自己的文件。MakeLore知识仅供检索。</p>}
|
||||
{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={busy || !!createIntent.current} onChange={e => setName(e.target.value)} />
|
||||
<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={busy || !!createIntent.current || catalogLoading || !!catalogError || !catalog?.models.length} onChange={e => setModel(e.target.value)}>
|
||||
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>
|
||||
@@ -74,15 +108,27 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: stri
|
||||
</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 () => {
|
||||
<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) { setKbId(kb.kb_id); setCreating(false); setName(''); setRefresh(v => v + 1); onCreated(kb); }
|
||||
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>}
|
||||
{kbId && <CloudKnowledgeDocuments key={slug + ':' + kbId} slug={slug} kbId={kbId} busy={busy} act={act}
|
||||
onDeleteKnowledge={() => { onDeleted?.(kbId); setKbId(''); setRefresh(v => v + 1); }} />}
|
||||
{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); }}>
|
||||
{documentPending ? '放弃未确认操作并重新选择' : '重新选择知识库'}
|
||||
</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} busy={managementDisabled || !selectedKnowledge}
|
||||
canRead={Boolean(selectedKnowledge)} act={act} onPendingChange={setDocumentPending}
|
||||
onDeleteKnowledge={() => { callbacks.current.onDeleted?.(kbId); setKbId(''); setDocumentPending(false); setRefresh(v => v + 1); }} /></div>}
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
@@ -47,10 +47,11 @@ function documentState(file: CloudKnowledgeFile) {
|
||||
return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 };
|
||||
}
|
||||
|
||||
export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge }: {
|
||||
export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange }: {
|
||||
slug: string; kbId: string; busy: boolean;
|
||||
act: (operation: () => Promise<void>) => Promise<void>;
|
||||
onDeleteKnowledge: () => void;
|
||||
canRead?: boolean; onPendingChange?: (pending: boolean) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<CloudKnowledgeFile[]>([]);
|
||||
const [pageCount, setPageCount] = useState(1);
|
||||
@@ -61,14 +62,21 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
|
||||
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>>({});
|
||||
const processIntents = useRef<Record<string, { operationId: string; previousTaskId?: string }>>({});
|
||||
const alive = useRef(true);
|
||||
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;
|
||||
usePendingCloudInput(unresolved);
|
||||
useEffect(() => { onPendingChange?.(unresolved); return () => onPendingChange?.(false); }, [onPendingChange, unresolved]);
|
||||
const refresh = () => setRevision(value => value + 1);
|
||||
useEffect(() => {
|
||||
if (!canRead) return;
|
||||
let current = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const load = async () => {
|
||||
@@ -101,9 +109,10 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
|
||||
};
|
||||
void load();
|
||||
return () => { current = false; clearTimeout(timer); };
|
||||
}, [slug, kbId, revision, pageCount]);
|
||||
}, [slug, kbId, revision, pageCount, canRead]);
|
||||
|
||||
const process = async (file: CloudKnowledgeFile) => {
|
||||
if (!alive.current || !permitted.current) return;
|
||||
setSubmitting(file.file_id);
|
||||
setProcessErrors(previous => { const value = { ...previous }; delete value[file.file_id]; return value; });
|
||||
const intent = processIntents.current[file.file_id] ??= {
|
||||
@@ -131,11 +140,12 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
|
||||
};
|
||||
const upload = (replaces?: CloudKnowledgeFile) => act(async () => {
|
||||
const key = replaces?.file_id ?? 'new';
|
||||
const retrying = Boolean(uploadIntents.current[key]);
|
||||
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 (file || !retrying) delete uploadIntents.current[key];
|
||||
if (alive.current) setUploading('');
|
||||
if (file) await acceptFile(file);
|
||||
} finally { if (alive.current) setUploading(''); }
|
||||
@@ -144,13 +154,14 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
|
||||
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>
|
||||
<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>
|
||||
<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); }} />}
|
||||
{importing && <AttachmentImport slug={slug} kbId={kbId} busy={busy || !canRead} act={act} onPendingChange={setImportPending}
|
||||
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>
|
||||
@@ -188,22 +199,22 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
|
||||
{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.pending || state.failed || error) && !state.active && <Button type="button" size="sm" variant="outline" disabled={busy || !!readError || (unresolved && !processIntents.current[file.file_id])} onClick={() => void act(() => process(file))}>
|
||||
{state.failed || error ? '重试处理' : '开始处理'}
|
||||
</Button>}
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy || active || !!readError} onClick={() => void upload(file)}>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy || active || !!readError || (unresolved && !uploadIntents.current[file.file_id])} 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>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy || active || !!readError || unresolved} 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>
|
||||
<div className="border-t pt-2"><Button type="button" variant="ghost" size="sm" disabled={busy || active || !!readError || unresolved} 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 () => {
|
||||
<div className="flex gap-2"><Button type="button" variant="outline" disabled={busy || unresolved} 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(); }
|
||||
@@ -212,9 +223,10 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AttachmentImport({ slug, kbId, busy, act, onImported }: {
|
||||
function AttachmentImport({ slug, kbId, busy, act, onImported, onPendingChange }: {
|
||||
slug: string; kbId: string; busy: boolean; act: (operation: () => Promise<void>) => Promise<void>;
|
||||
onImported: (file: CloudKnowledgeFile) => Promise<void>;
|
||||
onPendingChange: (pending: boolean) => void;
|
||||
}) {
|
||||
const [threads, setThreads] = useState<CloudThread[]>([]);
|
||||
const [thread, setThread] = useState('');
|
||||
@@ -222,6 +234,8 @@ function AttachmentImport({ slug, kbId, busy, act, onImported }: {
|
||||
const [file, setFile] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const operation = useRef<string | null>(null);
|
||||
const unresolved = Boolean(operation.current);
|
||||
useEffect(() => { onPendingChange(unresolved); return () => onPendingChange(false); }, [onPendingChange, unresolved]);
|
||||
usePendingCloudInput(busy || Boolean(operation.current));
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CloudKnowledgePanel, type CloudOwnedKnowledgeCatalog } from './CloudKnowledge';
|
||||
import type { CloudResource } from '../../../shared/cloud-agents';
|
||||
|
||||
export function CloudKnowledgeSelector({ slug, options, selected, disabled, loading, error, refreshToken, onChange, onRefresh }: {
|
||||
slug: string; options: CloudResource[]; selected: string[]; disabled: boolean; loading: boolean; error: string; refreshToken: number;
|
||||
onChange: (selected: string[]) => void; onRefresh: () => void;
|
||||
}) {
|
||||
const [owned, setOwned] = useState<CloudOwnedKnowledgeCatalog>({ databases: null, loading: true, error: '' });
|
||||
const ownedIds = owned.databases?.map(kb => kb.kb_id) ?? null;
|
||||
const available = options.filter(option => !selected.includes(option.key));
|
||||
const shared = ownedIds === null ? [] : available.filter(option => !ownedIds.includes(option.key));
|
||||
const personal = ownedIds === null ? [] : available.filter(option => ownedIds.includes(option.key));
|
||||
const unknown = ownedIds === null ? available : [];
|
||||
const selectionFull = selected.length >= 100;
|
||||
const add = (id: string) => { if (!selectionFull && !selected.includes(id)) onChange([...selected, id]); };
|
||||
return <div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select aria-label="选择知识库" className="min-w-0 flex-1 basis-48" disabled={disabled || loading || Boolean(error) || selectionFull || available.length === 0} value=""
|
||||
onChange={event => { if (!disabled && !loading && !error && event.target.value && available.some(option => option.key === event.target.value)) add(event.target.value); }}>
|
||||
<option value="">{loading ? '正在读取知识库…' : error ? '知识库目录暂不可用' : available.length ? '选择知识库,可选多个' : options.length ? '已选用全部可用知识库' : '暂无可选知识库'}</option>
|
||||
{shared.length > 0 && <optgroup label="MakeLore知识">{shared.map(option => <option key={option.key} value={option.key}>{option.name}</option>)}</optgroup>}
|
||||
{personal.length > 0 && <optgroup label="我的知识库">{personal.map(option => <option key={option.key} value={option.key}>{option.name}</option>)}</optgroup>}
|
||||
{unknown.length > 0 && <optgroup label="可选知识库">{unknown.map(option => <option key={option.key} value={option.key}>{option.name}</option>)}</optgroup>}
|
||||
</select>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={disabled || loading || owned.loading} onClick={onRefresh}>刷新知识库</Button>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{selectionFull ? '已选满100个知识库,请先移除一个再选择。' : '可多选,每次选择一个知识库加入配置。'}</p>
|
||||
{error ? <p role="alert" className="text-sm text-destructive">知识库目录读取失败,请刷新重试。</p>
|
||||
: !loading && ownedIds !== null && options.every(option => ownedIds.includes(option.key)) && <p className="text-xs leading-5 text-muted-foreground">暂无可选的MakeLore知识。平台开放给你读取的知识库会出现在这里。</p>}
|
||||
{owned.error && <p className="text-xs leading-5 text-muted-foreground">我的知识库目录暂不可用,仍可选用可读取的知识;上传文件请刷新重试。</p>}
|
||||
{selected.length > 0 ? <ul aria-label="已选知识库" className="space-y-2">
|
||||
{selected.map(id => {
|
||||
const option = options.find(item => item.key === id);
|
||||
const ownKnowledge = owned.databases?.find(item => item.kb_id === id);
|
||||
const name = option?.name ?? ownKnowledge?.name ?? id;
|
||||
return <li key={id} className="flex min-w-0 items-start gap-2 rounded-lg border bg-background/60 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-words text-sm font-medium [overflow-wrap:anywhere]">{name}
|
||||
{option && ownedIds !== null && !ownedIds.includes(id) && <span className="ml-2 text-xs font-normal text-muted-foreground">MakeLore知识 · 只读</span>}
|
||||
{!option && <span className="ml-2 text-xs font-normal text-muted-foreground">{loading || error ? '目录待确认' : '已不可用'}</span>}
|
||||
</p>
|
||||
{option?.description && <p className="mt-1 text-xs leading-5 text-muted-foreground">{option.description}</p>}
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={disabled} aria-label={'移除知识库 ' + name}
|
||||
onClick={() => onChange(selected.filter(value => value !== id))}><X aria-hidden="true" className="size-4" /></Button>
|
||||
</li>;
|
||||
})}
|
||||
</ul> : <p className="agent-resource-empty">还没有选择知识库</p>}
|
||||
<p className="text-xs leading-5 text-muted-foreground">选择后保存并应用配置,智能体即可使用这些知识。MakeLore知识只可检索,无法在这里修改。</p>
|
||||
<div className="agent-config-resource-manager"><CloudKnowledgePanel key={slug} slug={slug} disabled={disabled} onCatalog={setOwned} refreshToken={refreshToken}
|
||||
selectionFull={selectionFull} selectedIds={selected} selectableIds={loading || error ? null : options.map(option => option.key)}
|
||||
onSelected={kb => { if (!loading && !error && options.some(option => option.key === kb.kb_id)) { add(kb.kb_id); onRefresh(); } }}
|
||||
onDeleted={id => { onChange(selected.filter(value => value !== id)); onRefresh(); }}
|
||||
onCreated={kb => { add(kb.kb_id); onRefresh(); }} /></div>
|
||||
</div>;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { BookOpen, Brain, Library, Plug, RefreshCw, Users, Wrench } from 'lucide
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CloudKnowledgePanel } from './CloudKnowledge';
|
||||
import { CloudKnowledgeSelector } from './CloudKnowledgeSelector';
|
||||
import { AgentConfigHeading } from './AgentConfigHeading';
|
||||
import type { CloudAgentConfiguration, CloudCatalog } from '../../../shared/cloud-agents';
|
||||
|
||||
@@ -22,12 +22,15 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section =
|
||||
const [catalog, setCatalog] = useState<CloudCatalog | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [loadedCatalogRevision, setLoadedCatalogRevision] = useState(-1);
|
||||
const catalogLoading = loadedCatalogRevision !== refresh;
|
||||
const [search, setSearch] = useState('');
|
||||
const [choosing, setChoosing] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
cloudAgentsApi.call('catalog', {}).then(v => { if (live) { setCatalog(v); setError(''); } })
|
||||
.catch(e => { if (live) setError(e instanceof Error ? e.message : '配置目录暂不可用'); });
|
||||
.catch(e => { if (live) setError(e instanceof Error ? e.message : '配置目录暂不可用'); })
|
||||
.finally(() => { if (live) setLoadedCatalogRevision(refresh); });
|
||||
return () => { live = false; };
|
||||
}, [refresh]);
|
||||
const change = <K extends keyof CloudAgentConfiguration>(key: K, next: CloudAgentConfiguration[K]) => onChange({ ...value, [key]: next });
|
||||
@@ -56,8 +59,10 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section =
|
||||
<legend className="sr-only">{title}</legend>
|
||||
<div className="agent-config-section-top">
|
||||
<AgentConfigHeading title={title} icon={icon} note={'已选 ' + value[key].length} />
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => { setChoosing(choosing === key ? null : key); setSearch(''); }}>{choosing === key ? '完成选择' : '选择' + label}</Button>
|
||||
{key !== 'knowledges' && <Button type="button" variant="outline" size="sm" onClick={() => { setChoosing(choosing === key ? null : key); setSearch(''); }}>{choosing === key ? '完成选择' : '选择' + label}</Button>}
|
||||
</div>
|
||||
{key === 'knowledges' ? <CloudKnowledgeSelector key={slug} slug={slug} options={options} selected={value.knowledges} disabled={disabled}
|
||||
loading={catalogLoading} error={error} refreshToken={refresh} onChange={selected => change('knowledges', selected)} onRefresh={() => setRefresh(v => v + 1)} /> : <>
|
||||
{choosing === key && <Input className="mb-3" aria-label={'搜索' + label} placeholder={'搜索' + label} value={search} onChange={e => setSearch(e.target.value)} />}
|
||||
{visibleOptions.length > 0 ? <div className="agent-resource-options">
|
||||
{visibleOptions.map(option => <label key={option.key} className="agent-resource-option">
|
||||
@@ -68,12 +73,10 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section =
|
||||
<span><span>{option.name}</span>{option.description && <span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>}</span>
|
||||
</label>)}
|
||||
</div> : <p className="agent-resource-empty">{!catalog ? '正在读取资源…' : choosing === key && search ? '没有找到匹配的内容' : !options.length && !missing.length ? '当前没有可用资源' : '还没有选择'}</p>}
|
||||
</>}
|
||||
{key === 'skills' && value.skills.length > 0 && <label className="agent-resource-preload">
|
||||
<input type="checkbox" checked={value.preload_skills.length === value.skills.length}
|
||||
onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始运行时预加载已选做事手册</label>}
|
||||
{key === 'knowledges' && <div className="agent-config-resource-manager"><CloudKnowledgePanel slug={slug}
|
||||
onDeleted={id => { change('knowledges', value.knowledges.filter(selected => selected !== id)); setRefresh(v => v + 1); }}
|
||||
onCreated={kb => { change('knowledges', [...new Set([...value.knowledges, kb.kb_id])]); setRefresh(v => v + 1); }} /></div>}
|
||||
</fieldset>;
|
||||
})}
|
||||
</div>;
|
||||
|
||||
Reference in new issue
Block a user