149 lines
11 KiB
TypeScript
149 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Library, Upload } from 'lucide-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 { CloudAgentOperations, CloudKnowledge } from '../../../shared/cloud-agents';
|
|
|
|
import { CloudKnowledgeDocuments } from './CloudKnowledgeDocuments';
|
|
|
|
export interface CloudOwnedKnowledgeCatalog { databases: CloudKnowledge[] | null; loading: boolean; error: string }
|
|
|
|
export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, refreshToken = 0, disabled = false, selectableIds }: {
|
|
slug: string; onCreated: (kb: CloudKnowledge) => void; onDeleted?: (id: string) => void;
|
|
onCatalog?: (value: CloudOwnedKnowledgeCatalog) => void; refreshToken?: number; disabled?: boolean;
|
|
selectableIds?: string[] | null;
|
|
}) {
|
|
type CloudEmbeddingModel = CloudAgentOperations['knowledge']['output']['models'][number];
|
|
const [catalog, setCatalog] = useState<{ databases: CloudKnowledge[]; models: CloudEmbeddingModel[] } | null>(null);
|
|
const [kbId, setKbId] = useState('');
|
|
const [name, setName] = useState('');
|
|
const [model, setModel] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [errorTarget, setErrorTarget] = useState<'creation' | 'management'>('management');
|
|
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 [documentPending, setDocumentPending] = useState(false);
|
|
const [uploadTarget, setUploadTarget] = useState<HTMLDivElement | null>(null);
|
|
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('');
|
|
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, refreshToken]);
|
|
const act = async (operation: () => Promise<void>, target: 'creation' | 'management' = 'management') => {
|
|
if (disabled || operationInFlight.current) return;
|
|
operationInFlight.current = true;
|
|
setBusy(true); setError(''); setErrorTarget(target);
|
|
try { await operation(); }
|
|
catch(e) { if (alive.current) setError(e instanceof Error ? e.message : '知识库操作失败'); }
|
|
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));
|
|
const creationDetails = <div className="space-y-3">
|
|
{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 || !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);
|
|
}
|
|
}, 'creation')}>{createIntent.current ? '重试创建' : '创建知识库'}</Button>
|
|
</div>}
|
|
{errorTarget === 'creation' && error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
|
</div>;
|
|
return <div className="agent-knowledge-owned-workspace">
|
|
<section aria-label="上传自己的文件" className="agent-knowledge-upload">
|
|
<div className="agent-knowledge-panel-heading"><Upload aria-hidden="true" className="size-5" /><h3>上传自己的文件</h3></div>
|
|
<div className="agent-knowledge-create-action">
|
|
<Button type="button" variant="outline" disabled={disabled || busy || documentPending || Boolean(createIntent.current)} onClick={() => setCreating(v => !v)}>新建知识库</Button>
|
|
<div ref={setUploadTarget} className="agent-knowledge-upload-action">
|
|
{!selectedKnowledge && <Button type="button" disabled title="请先选择或新建自己的知识库">从电脑上传</Button>}
|
|
</div>
|
|
</div>
|
|
{creationDetails}
|
|
</section>
|
|
<section aria-label="查看我的知识库" className="agent-knowledge-management text-sm">
|
|
<div className="agent-knowledge-panel-heading"><Library aria-hidden="true" className="size-5" /><h3>查看我的知识库</h3></div>
|
|
<div className="agent-knowledge-management-controls">
|
|
<select aria-label="管理的知识库" value={kbId} disabled={managementDisabled || documentPending}
|
|
onChange={e => {
|
|
const selected = manageable.find(kb => kb.kb_id === e.target.value);
|
|
if (managementDisabled || documentPending) return;
|
|
setKbId(selected?.kb_id ?? ''); setError('');
|
|
}}>
|
|
<option value="">{catalogLoading ? '正在读取我的知识库…' : '选择我的知识库'}</option>
|
|
{kbId && !selectedKnowledge && <option value={kbId} disabled>知识库管理权限待确认</option>}
|
|
{manageable.map(kb => <option key={kb.kb_id} value={kb.kb_id}>
|
|
{kb.name}{selectableIds === null ? '(选用状态待确认)' : !bindable(kb.kb_id) ? '(当前暂不可选用)' : ''}
|
|
</option>)}
|
|
</select>
|
|
<Button type="button" variant="outline" disabled={disabled || busy || catalogLoading} onClick={() => setRefresh(v => v + 1)}>刷新知识库与模型</Button>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{errorTarget === 'management' && error && <p role="alert" className="text-destructive">{error}</p>}
|
|
{catalogError && <p role="alert" className="text-destructive">知识库与模型目录加载失败:{catalogError}。请刷新重试。</p>}
|
|
{documentPending && selectedKnowledge && <p role="status" className="text-xs leading-5 text-amber-800">有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。</p>}
|
|
{!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>}
|
|
{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}
|
|
uploadTarget={selectedKnowledge ? uploadTarget : null}
|
|
canRead={Boolean(selectedKnowledge)} act={act} onPendingChange={setDocumentPending}
|
|
onDeleteKnowledge={() => { callbacks.current.onDeleted?.(kbId); setKbId(''); setDocumentPending(false); setRefresh(v => v + 1); }} /></div>}
|
|
</div>
|
|
</section>
|
|
</div>;
|
|
}
|