76 lines
6.0 KiB
TypeScript
76 lines
6.0 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Download, Folder, Paperclip } from 'lucide-react';
|
|
import { usePendingCloudInput } from './CloudPending';
|
|
import { Button } from '@/components/ui/button';
|
|
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
|
import type { CloudAttachment, CloudFile, CloudUpload } from '../../../shared/cloud-agents';
|
|
|
|
export function CloudFiles({ threadId, ensureThread, selected, onSelected, onBusy, disabled }: {
|
|
threadId?: string; ensureThread: () => Promise<string>; selected: string[]; onSelected: (ids: string[]) => void;
|
|
onBusy: (busy: boolean) => void; disabled: boolean;
|
|
}) {
|
|
const [attachments, setAttachments] = useState<CloudAttachment[]>([]);
|
|
const [files, setFiles] = useState<CloudFile[]>([]);
|
|
const [path, setPath] = useState('/');
|
|
const [upload, setUpload] = useState<CloudUpload | null>(null);
|
|
const [parseMethod, setParseMethod] = useState('disable');
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [refresh, setRefresh] = useState(0);
|
|
usePendingCloudInput(busy || Boolean(upload));
|
|
useEffect(() => {
|
|
if (!threadId) return;
|
|
let live = true;
|
|
Promise.all([cloudAgentsApi.call('attachments', { thread_id: threadId }), cloudAgentsApi.call('files', { thread_id: threadId, path })])
|
|
.then(([a, f]) => { if (live) { setAttachments(a.attachments); setFiles(f.files); } })
|
|
.catch(e => { if (live) setError(e instanceof Error ? e.message : '读取文件失败'); });
|
|
return () => { live = false; };
|
|
}, [threadId, path, refresh]);
|
|
const act = async (task: () => Promise<unknown>) => {
|
|
setBusy(true); onBusy(true); setError('');
|
|
try { await task(); setRefresh(v => v + 1); }
|
|
catch(e) { setError(e instanceof Error ? e.message : '文件操作失败'); }
|
|
finally { setBusy(false); onBusy(false); }
|
|
};
|
|
return <details className="my-3 rounded-lg border border-border px-3 py-2 text-sm">
|
|
<summary className="min-h-8 cursor-pointer py-1">附件与产物 {selected.length ? '· 本条消息附带 ' + selected.length + ' 个文件' : ''}</summary>
|
|
<div className="mt-3 space-y-3">
|
|
<div className="flex items-center gap-2"><Button variant="outline" size="sm" disabled={disabled || busy || Boolean(upload)} onClick={() => void act(async () => {
|
|
const picked = await cloudAgentsApi.upload();
|
|
if (picked) { setUpload(picked); setParseMethod('disable'); }
|
|
})}><Paperclip className="mr-2 h-4 w-4" />添加附件</Button>
|
|
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setRefresh(v => v + 1)}>刷新文件</Button></div>
|
|
{upload && <div className="space-y-2 rounded-lg bg-muted/40 p-3"><p>{upload.file_name}</p>
|
|
{upload.parse_supported && <label className="block text-xs">读取方式<select aria-label="附件读取方式" className="ml-2 h-10 rounded-md border border-input bg-background px-2" value={parseMethod} disabled={busy} onChange={e => setParseMethod(e.target.value)}>
|
|
<option value="disable">保留原始文件</option>{upload.parse_methods.filter(m => m !== 'disable').map(method => <option key={method} value={method}>{method === 'rapid_ocr' ? '本地文字识别' : method}</option>)}
|
|
</select></label>}
|
|
<div className="flex gap-2"><Button size="sm" disabled={busy} onClick={() => void act(async () => {
|
|
const id = await ensureThread();
|
|
const parsed = parseMethod === 'disable' ? undefined : await cloudAgentsApi.call('parseAttachment', { object_name: upload.object_name, parse_method: parseMethod });
|
|
const result = await cloudAgentsApi.call('confirmAttachment', { thread_id: id, attachments: [{
|
|
object_name: upload.object_name, file_type: upload.file_type, ...(parsed ? { parsed_object_name: parsed.parsed_object_name } : {}),
|
|
}] });
|
|
onSelected([...selected, ...result.attachments.map(a => a.file_id)]); setUpload(null);
|
|
})}>加入此对话</Button><Button variant="ghost" size="sm" disabled={busy} onClick={() => setUpload(null)}>取消</Button></div></div>}
|
|
{attachments.map(file => <label key={file.file_id} className="flex min-h-10 items-center gap-2">
|
|
<input type="checkbox" disabled={disabled || busy} checked={selected.includes(file.file_id)} onChange={e => onSelected(e.target.checked ? [...selected, file.file_id] : selected.filter(id => id !== file.file_id))} />
|
|
<span className="min-w-0 flex-1 truncate">{file.file_name}</span><span className="text-xs text-muted-foreground tabular-nums">{Math.ceil(file.file_size / 1024)} KB</span>
|
|
<Button variant="ghost" size="sm" disabled={disabled || busy} aria-label={'删除附件 ' + file.file_name} onClick={event => { event.preventDefault(); void act(async () => {
|
|
await cloudAgentsApi.call('deleteAttachment', { thread_id: threadId!, file_id: file.file_id });
|
|
onSelected(selected.filter(id => id !== file.file_id));
|
|
}); }}>删除</Button>
|
|
</label>)}
|
|
{threadId && <div className="space-y-2 border-t border-border pt-3">
|
|
<div className="flex items-center justify-between"><p className="break-all text-xs text-muted-foreground">会话文件 · {path}</p>
|
|
{path !== '/' && <Button variant="ghost" size="sm" onClick={() => setPath(path.split('/').slice(0, -1).join('/') || '/')}>上一级</Button>}</div>
|
|
{files.map(file => <div key={file.name} className="flex min-h-10 items-center justify-between gap-3">
|
|
{file.is_dir ? <Button variant="ghost" className="min-w-0 justify-start" onClick={() => setPath(file.directory_path)}><Folder className="mr-2 h-4 w-4" /><span className="truncate">{file.name}</span></Button>
|
|
: <><span className="min-w-0 truncate">{file.name}</span><Button variant="ghost" aria-label={'保存 ' + file.name} disabled={busy}
|
|
onClick={() => void act(() => cloudAgentsApi.download(threadId, file.path))}><Download className="h-4 w-4" /></Button></>}
|
|
</div>)}
|
|
</div>}
|
|
{error && <p role="alert" className="text-xs text-destructive">{error}。可刷新文件列表核对已上传的附件。</p>}
|
|
</div>
|
|
</details>;
|
|
}
|